Fix material targeting and update MetaCore branding

This commit is contained in:
ayuan9957 2026-03-24 09:12:37 +08:00
parent f17073160c
commit a6986002a7
27 changed files with 945 additions and 136 deletions

BIN
Assets/Models/dance.glb Normal file

Binary file not shown.

View File

@ -0,0 +1,8 @@
{
"guid": "ffc57139207e4c6a9677f6c29c90baed",
"asset_type": "model",
"source_hash": "facc0e3a9d60eda47d2a42e34922cbf09173479704536cde6f1bca4e90f5f431",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": []
}

BIN
Assets/Models/jxb.glb Normal file

Binary file not shown.

View File

@ -0,0 +1,8 @@
{
"guid": "36dd2eccd8314fb6a0bd8a5090bad6b1",
"asset_type": "model",
"source_hash": "7916e67bf644e61e2d7b7776ef8df40ef8623aaceda16b873c64d0a3f1a4faba",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": []
}

View File

@ -0,0 +1,8 @@
{
"guid": "7d4bce696eb848338bdcaffbacdbc4b1",
"asset_type": "model",
"source_hash": "46fc525a88f6d16eed0bac714a14692b68818d36a00065690b5877d878788948",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": []
}

Binary file not shown.

View File

@ -0,0 +1,8 @@
{
"guid": "98fc2abf779348b6b78d0be52d79993e",
"asset_type": "model",
"source_hash": "c57c36f52fca892855f8d8f0e70a2ceb8889b159f1c40fbbbcddc7d874682353",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": []
}

View File

@ -10,7 +10,7 @@
"importer": "model_importer", "importer": "model_importer",
"import_settings": {}, "import_settings": {},
"dependency_guids": [], "dependency_guids": [],
"updated_at": "2026-03-19 17:17:48", "updated_at": "2026-03-23 14:37:54",
"imported_cache": { "imported_cache": {
"root": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d", "root": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d",
"model_bam": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/model.bam", "model_bam": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/model.bam",
@ -18,9 +18,85 @@
"materials": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/materials.json", "materials": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/materials.json",
"import_info": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json" "import_info": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/import_info.json"
} }
},
"36dd2eccd8314fb6a0bd8a5090bad6b1": {
"guid": "36dd2eccd8314fb6a0bd8a5090bad6b1",
"asset_path": "Assets/Models/jxb.glb",
"asset_type": "model",
"meta_path": "Assets/Models/jxb.glb.meta",
"source_hash": "7916e67bf644e61e2d7b7776ef8df40ef8623aaceda16b873c64d0a3f1a4faba",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": [],
"updated_at": "2026-03-23 14:34:07",
"imported_cache": {
"root": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1",
"model_bam": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/model.bam",
"hierarchy": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/hierarchy.json",
"materials": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/materials.json",
"import_info": "Library/Imported/36dd2eccd8314fb6a0bd8a5090bad6b1/import_info.json"
}
},
"98fc2abf779348b6b78d0be52d79993e": {
"guid": "98fc2abf779348b6b78d0be52d79993e",
"asset_path": "Assets/Models/气体检测报警仪_跑动.fbx",
"asset_type": "model",
"meta_path": "Assets/Models/气体检测报警仪_跑动.fbx.meta",
"source_hash": "c57c36f52fca892855f8d8f0e70a2ceb8889b159f1c40fbbbcddc7d874682353",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": [],
"updated_at": "2026-03-23 14:34:08",
"imported_cache": {
"root": "Library/Imported/98fc2abf779348b6b78d0be52d79993e",
"model_bam": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/model.bam",
"hierarchy": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/hierarchy.json",
"materials": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/materials.json",
"import_info": "Library/Imported/98fc2abf779348b6b78d0be52d79993e/import_info.json"
}
},
"ffc57139207e4c6a9677f6c29c90baed": {
"guid": "ffc57139207e4c6a9677f6c29c90baed",
"asset_path": "Assets/Models/dance.glb",
"asset_type": "model",
"meta_path": "Assets/Models/dance.glb.meta",
"source_hash": "facc0e3a9d60eda47d2a42e34922cbf09173479704536cde6f1bca4e90f5f431",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": [],
"updated_at": "2026-03-23 14:34:07",
"imported_cache": {
"root": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed",
"model_bam": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/model.bam",
"hierarchy": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/hierarchy.json",
"materials": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/materials.json",
"import_info": "Library/Imported/ffc57139207e4c6a9677f6c29c90baed/import_info.json"
}
},
"7d4bce696eb848338bdcaffbacdbc4b1": {
"guid": "7d4bce696eb848338bdcaffbacdbc4b1",
"asset_path": "Assets/Models/jyc.glb",
"asset_type": "model",
"meta_path": "Assets/Models/jyc.glb.meta",
"source_hash": "46fc525a88f6d16eed0bac714a14692b68818d36a00065690b5877d878788948",
"importer": "model_importer",
"import_settings": {},
"dependency_guids": [],
"updated_at": "2026-03-23 14:35:25",
"imported_cache": {
"root": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1",
"model_bam": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/model.bam",
"hierarchy": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/hierarchy.json",
"materials": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/materials.json",
"import_info": "Library/Imported/7d4bce696eb848338bdcaffbacdbc4b1/import_info.json"
}
} }
}, },
"path_to_guid": { "path_to_guid": {
"Assets/Models/box1.glb": "90ece77e67b54ccda38d2de71cb4694d" "Assets/Models/box1.glb": "90ece77e67b54ccda38d2de71cb4694d",
"Assets/Models/jxb.glb": "36dd2eccd8314fb6a0bd8a5090bad6b1",
"Assets/Models/气体检测报警仪_跑动.fbx": "98fc2abf779348b6b78d0be52d79993e",
"Assets/Models/dance.glb": "ffc57139207e4c6a9677f6c29c90baed",
"Assets/Models/jyc.glb": "7d4bce696eb848338bdcaffbacdbc4b1"
} }
} }

View File

@ -3,5 +3,5 @@
"asset_path": "Assets/Models/box1.glb", "asset_path": "Assets/Models/box1.glb",
"asset_type": "model", "asset_type": "model",
"source_hash": "fc694905c47a9b0005d77b701cc41852b56ef08c7406829a306e98f3ce158a64", "source_hash": "fc694905c47a9b0005d77b701cc41852b56ef08c7406829a306e98f3ce158a64",
"generated_at": "2026-03-19 17:17:48" "generated_at": "2026-03-23 14:37:54"
} }

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
index-aa08c2.boo

View File

@ -51,6 +51,7 @@ class CoreWorld(ShowBase):
# 设置基本配置 # 设置基本配置
loadPrcFileData("", "show-frame-rate-meter 0") loadPrcFileData("", "show-frame-rate-meter 0")
loadPrcFileData("", "window-type onscreen") loadPrcFileData("", "window-type onscreen")
loadPrcFileData("", "window-title MetaCore")
loadPrcFileData("", f"win-size {width} {height}") loadPrcFileData("", f"win-size {width} {height}")
loadPrcFileData("", "win-fixed-size #f") # 允许窗口调整大小 loadPrcFileData("", "win-fixed-size #f") # 允许窗口调整大小
@ -81,6 +82,13 @@ class CoreWorld(ShowBase):
# 初始化 ShowBase # 初始化 ShowBase
ShowBase.__init__(self) ShowBase.__init__(self)
try:
props = WindowProperties()
props.setTitle("MetaCore")
self.win.requestProperties(props)
except Exception:
pass
# 创建渲染管线 # 创建渲染管线
self.render_pipeline.create(self) self.render_pipeline.create(self)
install_editor_tag_state_manager(self.render_pipeline, self) install_editor_tag_state_manager(self.render_pipeline, self)

View File

@ -31,19 +31,19 @@ DockId=0x0000000D,0
[Window][场景树] [Window][场景树]
Pos=0,20 Pos=0,20
Size=274,1331 Size=339,1084
Collapsed=0 Collapsed=0
DockId=0x00000007,0 DockId=0x00000007,0
[Window][属性面板] [Window][属性面板]
Pos=2167,20 Pos=1655,20
Size=393,1331 Size=393,1084
Collapsed=0 Collapsed=0
DockId=0x00000002,0 DockId=0x00000002,0
[Window][控制台] [Window][控制台]
Pos=276,952 Pos=341,705
Size=1889,399 Size=1312,399
Collapsed=0 Collapsed=0
DockId=0x00000006,1 DockId=0x00000006,1
@ -59,7 +59,7 @@ Collapsed=0
[Window][WindowOverViewport_11111111] [Window][WindowOverViewport_11111111]
Pos=0,20 Pos=0,20
Size=2560,1331 Size=2048,1084
Collapsed=0 Collapsed=0
[Window][测试窗口1] [Window][测试窗口1]
@ -98,8 +98,8 @@ Size=600,500
Collapsed=0 Collapsed=0
[Window][资源管理器] [Window][资源管理器]
Pos=276,952 Pos=341,705
Size=1889,399 Size=1312,399
Collapsed=0 Collapsed=0
DockId=0x00000006,0 DockId=0x00000006,0
@ -134,7 +134,7 @@ Size=89,250
Collapsed=0 Collapsed=0
[Window][颜色选择器] [Window][颜色选择器]
Pos=874,352 Pos=878,354
Size=300,400 Size=300,400
Collapsed=0 Collapsed=0
@ -214,11 +214,21 @@ Pos=1010,515
Size=540,320 Size=540,320
Collapsed=0 Collapsed=0
[Window][关于 EG]
Pos=794,422
Size=460,260
Collapsed=0
[Window][关于 MetaCore]
Pos=794,422
Size=460,260
Collapsed=0
[Docking][Data] [Docking][Data]
DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2560,1331 Split=X DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=2048,1084 Split=X
DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1525,1012 Split=X DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1525,1012 Split=X
DockNode ID=0x00000007 Parent=0x00000001 SizeRef=274,1084 Selected=0xE0015051 DockNode ID=0x00000007 Parent=0x00000001 SizeRef=339,1084 Selected=0xE0015051
DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1249,1084 Split=Y DockNode ID=0x00000008 Parent=0x00000001 SizeRef=1312,1084 Split=Y
DockNode ID=0x00000005 Parent=0x00000008 SizeRef=2048,683 Split=Y DockNode ID=0x00000005 Parent=0x00000008 SizeRef=2048,683 Split=Y
DockNode ID=0x0000000D Parent=0x00000005 SizeRef=1318,383 HiddenTabBar=1 Selected=0x43A39006 DockNode ID=0x0000000D Parent=0x00000005 SizeRef=1318,383 HiddenTabBar=1 Selected=0x43A39006
DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051 DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051

View File

@ -1008,7 +1008,7 @@ class ProjectManager:
load_lui_fn(temp_stub) load_lui_fn(temp_stub)
except Exception: except Exception:
pass pass
return bool(ssbo_loaded or built_nodes or built_spot_lights or built_point_lights or scene_components) return bool(ssbo_loaded or built_model_nodes or built_spot_lights or built_point_lights or scene_components)
def _iter_top_level_scene_asset_nodes(self, scene_description): def _iter_top_level_scene_asset_nodes(self, scene_description):
nodes = list(scene_description.get("nodes", []) or []) nodes = list(scene_description.get("nodes", []) or [])
@ -1089,18 +1089,176 @@ class ProjectManager:
target_np.setTag("file", os.path.basename(asset_abs_path)) target_np.setTag("file", os.path.basename(asset_abs_path))
if imported_node_key: if imported_node_key:
target_np.setTag("imported_node_key", imported_node_key) target_np.setTag("imported_node_key", imported_node_key)
target_np.setTag("is_model_root", "1") if asset_guid:
target_np.setTag("is_scene_element", "1") target_np.setTag("is_model_root", "1")
target_np.setTag("is_scene_element", "1")
runtime_interactive = bool(metadata_component.get("runtime_interactive", node.get("runtime_interactive", False))) runtime_interactive = bool(metadata_component.get("runtime_interactive", node.get("runtime_interactive", False)))
if runtime_interactive: if runtime_interactive:
target_np.setTag("runtime_interactive", "true") target_np.setTag("runtime_interactive", "true")
for tag_name in (
"has_animations",
"has_animations_checked",
"can_create_actor_from_memory",
"saved_has_animations",
"saved_can_create_actor_from_memory",
):
tag_value = str(
metadata_component.get(tag_name, (node.get("tags", {}) or {}).get(tag_name, ""))
or ""
).strip()
if tag_value:
target_np.setTag(tag_name, tag_value)
if asset_path:
asset_abs_path = os.path.join(project_path, asset_path.replace("/", os.sep))
if not target_np.hasTag("saved_model_path"):
target_np.setTag("saved_model_path", asset_abs_path)
scripts = list(scripts_component.get("entries", []) or node.get("scripts", []) or []) scripts = list(scripts_component.get("entries", []) or node.get("scripts", []) or [])
if scripts: if scripts:
target_np.setTag("has_scripts", "true") target_np.setTag("has_scripts", "true")
target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False)) target_np.setTag("scripts_info", json.dumps(scripts, ensure_ascii=False))
self._apply_saved_material_tags_to_node(target_np)
def _apply_saved_material_tags_to_node(self, target_np):
"""Rebuild runtime material state from serialized material_* tags."""
if not target_np or target_np.isEmpty():
return
property_helpers = getattr(self.world, "property_helpers", None)
if not property_helpers:
return
material_tag_names = (
"material_basecolor",
"material_emission",
"material_roughness",
"material_metallic",
"material_ior",
)
if not any(target_np.hasTag(tag_name) for tag_name in material_tag_names):
return
try:
from panda3d.core import Vec4
except Exception:
Vec4 = None
def _parse_vec4(raw_value):
try:
cleaned = str(raw_value or "").strip()
for prefix in ("LVecBase4f", "Vec4", "LColor"):
cleaned = cleaned.replace(prefix, "")
cleaned = cleaned.strip("() ")
values = [float(part.strip()) for part in cleaned.split(",") if part.strip()]
if len(values) == 3:
values.append(1.0)
if len(values) >= 4:
return tuple(values[:4])
except Exception:
pass
return None
def _parse_float(tag_name):
try:
return float(target_np.getTag(tag_name))
except Exception:
return None
ensure_material_fn = getattr(property_helpers, "_ensure_material_for_node", None)
sync_runtime_fn = getattr(property_helpers, "_sync_material_node_runtime", None)
set_base_color_fn = getattr(property_helpers, "_set_material_base_color", None)
if not callable(ensure_material_fn):
return
try:
material = ensure_material_fn(target_np)
except Exception:
material = None
if material is None:
return
base_color = _parse_vec4(target_np.getTag("material_basecolor")) if target_np.hasTag("material_basecolor") else None
if base_color is not None and callable(set_base_color_fn):
try:
set_base_color_fn(material, base_color)
except Exception:
pass
emission = _parse_vec4(target_np.getTag("material_emission")) if target_np.hasTag("material_emission") else None
if emission is not None and Vec4 is not None and hasattr(material, "set_emission"):
try:
material.set_emission(Vec4(*emission))
except Exception:
pass
roughness = _parse_float("material_roughness")
if roughness is not None and hasattr(material, "set_roughness"):
try:
material.set_roughness(roughness)
except Exception:
pass
metallic = _parse_float("material_metallic")
if metallic is not None and hasattr(material, "set_metallic"):
try:
material.set_metallic(metallic)
except Exception:
pass
ior = _parse_float("material_ior")
if ior is not None and hasattr(material, "set_refractive_index"):
try:
material.set_refractive_index(ior)
except Exception:
pass
if callable(sync_runtime_fn):
try:
sync_runtime_fn(target_np, material, refresh_ssbo_runtime=False)
except Exception:
pass
def _apply_scene_description_state_to_subtree(self, target_np, node, project_path, node_lookup):
"""Apply saved state to one imported node and its serialized descendants by child order."""
if not target_np or target_np.isEmpty() or not isinstance(node, dict):
return
self._apply_scene_description_state_to_node(target_np, node, project_path)
node_id = str(node.get("node_id", "") or "").strip()
if not node_id:
return
child_nodes = []
for candidate in list((node_lookup or {}).values()):
if str(candidate.get("parent_id", "") or "").strip() != node_id:
continue
child_nodes.append(candidate)
def _node_index(candidate):
candidate_id = str(candidate.get("node_id", "") or "")
try:
return int(candidate_id.rsplit("/", 1)[-1])
except Exception:
return 0
child_nodes.sort(key=_node_index)
runtime_children = []
try:
runtime_children = [child for child in target_np.getChildren() if child and not child.isEmpty()]
except Exception:
runtime_children = []
for child_entry in child_nodes:
child_index = _node_index(child_entry)
if child_index < 0 or child_index >= len(runtime_children):
continue
self._apply_scene_description_state_to_subtree(runtime_children[child_index], child_entry, project_path, node_lookup)
def _load_scene_description_via_ssbo(self, scene_description, project_path, asset_database): def _load_scene_description_via_ssbo(self, scene_description, project_path, asset_database):
if not getattr(self.world, "use_ssbo_mouse_picking", False): if not getattr(self.world, "use_ssbo_mouse_picking", False):
return False return False
@ -1109,6 +1267,8 @@ class ProjectManager:
if not ssbo_editor: if not ssbo_editor:
return False return False
refresh_runtime_fn = getattr(ssbo_editor, "refresh_runtime_from_source", None)
top_level_asset_nodes, node_lookup = self._iter_top_level_scene_asset_nodes(scene_description) top_level_asset_nodes, node_lookup = self._iter_top_level_scene_asset_nodes(scene_description)
if not top_level_asset_nodes: if not top_level_asset_nodes:
return False return False
@ -1133,6 +1293,33 @@ class ProjectManager:
if not os.path.exists(asset_abs): if not os.path.exists(asset_abs):
continue continue
has_saved_animation = False
try:
metadata_component = dict(components.get("metadata", {}) or {})
tags = dict(node.get("tags", {}) or {})
has_saved_animation = (
str(tags.get("has_animations", "")).lower() == "true"
or str(tags.get("saved_has_animations", "")).lower() == "true"
or str(metadata_component.get("has_animations", "")).lower() == "true"
or str(metadata_component.get("saved_has_animations", "")).lower() == "true"
or str(metadata_component.get("can_create_actor_from_memory", "")).lower() == "true"
or str(metadata_component.get("saved_can_create_actor_from_memory", "")).lower() == "true"
)
except Exception:
has_saved_animation = False
if has_saved_animation:
scene_manager = getattr(self.world, "scene_manager", None)
if scene_manager and hasattr(scene_manager, "importModel"):
try:
imported_np = scene_manager.importModel(asset_abs)
except Exception:
imported_np = None
if imported_np and not imported_np.isEmpty():
self._apply_scene_description_state_to_node(imported_np, node, project_path)
loaded_any = True
continue
try: try:
imported_root = ssbo_editor.load_model( imported_root = ssbo_editor.load_model(
asset_abs, asset_abs,
@ -1148,7 +1335,15 @@ class ProjectManager:
if target_root is None: if target_root is None:
continue continue
self._apply_scene_description_state_to_node(target_root, node, project_path) self._apply_scene_description_state_to_subtree(target_root, node, project_path, node_lookup)
# Keep runtime aligned with source after restoring per-node transforms.
# Otherwise the next append=True import snapshots stale runtime poses
# back into source_model_root and overwrites the restored transform.
if callable(refresh_runtime_fn):
try:
refresh_runtime_fn(preserve_selection=False)
except Exception:
pass
loaded_any = True loaded_any = True
if not loaded_any: if not loaded_any:
@ -1494,6 +1689,14 @@ class ProjectManager:
print("错误: 请先创建或打开一个项目!") print("错误: 请先创建或打开一个项目!")
return False return False
ssbo_editor = getattr(self.world, "ssbo_editor", None)
snapshot_material_fn = getattr(ssbo_editor, "_snapshot_runtime_materials_to_source_root", None) if ssbo_editor else None
if callable(snapshot_material_fn):
try:
snapshot_material_fn()
except Exception as e:
print(f"⚠️ 保存前同步 SSBO 材质到源场景失败: {e}")
project_path = self.current_project_path project_path = self.current_project_path
ensure_project_directories(ProjectLayout(project_path)) ensure_project_directories(ProjectLayout(project_path))
project_config = self._ensure_v2_project_defaults(project_path, dict(self.project_config or {})) project_config = self._ensure_v2_project_defaults(project_path, dict(self.project_config or {}))
@ -2213,8 +2416,11 @@ class ProjectManager:
for model in list(getattr(scene_manager, "models", []) or []): for model in list(getattr(scene_manager, "models", []) or []):
try: try:
if tree_widget: if tree_widget:
tree_widget.delete_item(model) try:
elif model and not model.isEmpty(): tree_widget.delete_item(model)
except Exception:
pass
if model and not model.isEmpty():
model.removeNode() model.removeNode()
except Exception: except Exception:
pass pass
@ -2225,8 +2431,11 @@ class ProjectManager:
for light_node in collection: for light_node in collection:
try: try:
if tree_widget: if tree_widget:
tree_widget.delete_item(light_node) try:
elif light_node and not light_node.isEmpty(): tree_widget.delete_item(light_node)
except Exception:
pass
if light_node and not light_node.isEmpty():
light_node.removeNode() light_node.removeNode()
except Exception: except Exception:
pass pass
@ -2237,8 +2446,11 @@ class ProjectManager:
for terrain in terrains: for terrain in terrains:
try: try:
if tree_widget: if tree_widget:
tree_widget.delete_item(terrain) try:
elif terrain and not terrain.isEmpty(): tree_widget.delete_item(terrain)
except Exception:
pass
if terrain and not terrain.isEmpty():
terrain.removeNode() terrain.removeNode()
except Exception: except Exception:
pass pass

View File

@ -174,10 +174,20 @@ def _collect_script_component(scripts: list[dict]) -> dict:
def _collect_node_metadata_component(node, runtime_interactive: bool) -> dict: def _collect_node_metadata_component(node, runtime_interactive: bool) -> dict:
return { metadata = {
"node_class": node.node().getClassType().getName() if node.node() else "", "node_class": node.node().getClassType().getName() if node.node() else "",
"runtime_interactive": runtime_interactive, "runtime_interactive": runtime_interactive,
} }
for tag_name in (
"has_animations",
"has_animations_checked",
"can_create_actor_from_memory",
"saved_has_animations",
"saved_can_create_actor_from_memory",
):
if node.hasTag(tag_name):
metadata[tag_name] = node.getTag(tag_name)
return metadata
def _collect_material_override_component(node): def _collect_material_override_component(node):
@ -232,6 +242,10 @@ def _resolve_imported_node_key(node, fallback_key: str) -> str:
def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], runtime_interactive: bool, light_component: dict, material_component: dict) -> bool: def _should_serialize_child_nodes(node, asset_guid: str, scripts: list[dict], runtime_interactive: bool, light_component: dict, material_component: dict) -> bool:
if not node: if not node:
return False return False
# Imported model roots and their descendants must keep hierarchy state so
# per-child transforms/material overrides survive save/load.
if asset_guid:
return True
if not asset_guid: if not asset_guid:
return True return True
if scripts: if scripts:
@ -447,6 +461,37 @@ def build_runtime_scene(scene_description: dict):
for node in nodes: for node in nodes:
if not node.get("asset_guid"): if not node.get("asset_guid"):
continue continue
metadata_component = dict((node.get("components", {}) or {}).get("metadata", {}) or {})
has_animations = str(
metadata_component.get(
"has_animations",
metadata_component.get(
"saved_has_animations",
(node.get("tags", {}) or {}).get(
"has_animations",
(node.get("tags", {}) or {}).get("saved_has_animations", ""),
),
),
)
or ""
).lower() == "true"
can_create_actor = str(
metadata_component.get(
"can_create_actor_from_memory",
metadata_component.get(
"saved_can_create_actor_from_memory",
(node.get("tags", {}) or {}).get(
"can_create_actor_from_memory",
(node.get("tags", {}) or {}).get("saved_can_create_actor_from_memory", ""),
),
),
)
or ""
).lower() == "true"
if has_animations or can_create_actor:
interactive_node_ids.append(node.get("node_id", ""))
interactive_model_names.append(node.get("name", ""))
continue
if node.get("runtime_interactive"): if node.get("runtime_interactive"):
interactive_node_ids.append(node.get("node_id", "")) interactive_node_ids.append(node.get("node_id", ""))
interactive_model_names.append(node.get("name", "")) interactive_model_names.append(node.get("name", ""))

View File

@ -242,6 +242,20 @@ class ObjectController:
return False return False
def get_preferred_selection_ids(self, key):
"""Return the IDs that should be selected when a tree node is clicked.
Prefer the node's own local geometry so parent transforms remain directly
selectable. Only fall back to the aggregated subtree when the node itself
has no local renderable geometry.
"""
node = self.tree_nodes.get(key)
if node:
local_ids = list(node.get("local_ids", []) or [])
if local_ids:
return local_ids
return list(self.name_to_ids.get(key, []) or [])
def _encode_id_color(self, vdata, object_id): def _encode_id_color(self, vdata, object_id):
if not vdata.has_column("color"): if not vdata.has_column("color"):
new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4()) new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())

View File

@ -79,6 +79,7 @@ class SSBOEditor:
# Internal State # Internal State
self.selected_name = None self.selected_name = None
self.selected_ids = [] self.selected_ids = []
self.transform_ids = []
self.search_text = "" self.search_text = ""
self.last_search_text = None self.last_search_text = None
self.filtered_nodes = [] self.filtered_nodes = []
@ -666,22 +667,37 @@ class SSBOEditor:
if not self._node_is_valid(source_node): if not self._node_is_valid(source_node):
continue 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 source_snapshot = None
if callable(capture_snapshot_fn): if callable(capture_snapshot_fn):
try: try:
source_snapshot = capture_snapshot_fn(obj_np) source_snapshot = capture_snapshot_fn(source_node)
except Exception: except Exception:
source_snapshot = None source_snapshot = None
source_node_key = id(source_node) source_node_key = id(source_node)
entry = grouped_entries.get(source_node_key) entry = grouped_entries.get(source_node_key)
candidate_score = _snapshot_score(source_snapshot, obj_np) 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"]: if entry is None or candidate_score > entry["score"]:
grouped_entries[source_node_key] = { grouped_entries[source_node_key] = {
"gid": gid, "gid": gid,
"obj_np": obj_np, "obj_np": obj_np,
"source_node": source_node, "source_node": source_node,
"snapshot": source_snapshot, "snapshot": chosen_snapshot,
"score": candidate_score, "score": candidate_score,
} }
@ -870,6 +886,7 @@ class SSBOEditor:
self.model = None self.model = None
self.selected_name = None self.selected_name = None
self.selected_ids = [] self.selected_ids = []
self.transform_ids = []
self.last_import_tree_key = None self.last_import_tree_key = None
self.last_import_root_name = None self.last_import_root_name = None
if not preserve_source_models: if not preserve_source_models:
@ -1957,13 +1974,14 @@ class SSBOEditor:
if not self.controller: if not self.controller:
return return
self._sync_pick_root_transform() self._sync_pick_root_transform()
if not self.selected_ids: transform_ids = self.get_selection_transform_ids()
if not transform_ids:
return return
# Group selection can contain thousands of objects. # Group selection can contain thousands of objects.
# Only resync when proxy transform has changed. # Only resync when proxy transform has changed.
proxy = getattr(self, "_group_proxy", None) proxy = getattr(self, "_group_proxy", None)
if proxy and not proxy.is_empty() and len(self.selected_ids) > 1: if proxy and not proxy.is_empty() and len(transform_ids) > 1:
proxy_world = proxy.get_mat(self.base.render) 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): if self._last_group_sync_mat and self._matrices_close(proxy_world, self._last_group_sync_mat):
return return
@ -1971,8 +1989,8 @@ class SSBOEditor:
else: else:
self._last_group_sync_mat = None self._last_group_sync_mat = None
if len(self.selected_ids) == 1: if len(transform_ids) == 1:
gid = self.selected_ids[0] gid = transform_ids[0]
obj_np = self.controller.id_to_object_np.get(gid) obj_np = self.controller.id_to_object_np.get(gid)
pick_np = self.controller.id_to_pick_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()): if not (obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty()):
@ -1996,7 +2014,7 @@ class SSBOEditor:
self._last_single_sync_gid = None self._last_single_sync_gid = None
self._last_single_sync_mat = None self._last_single_sync_mat = None
for gid in self.selected_ids: for gid in transform_ids:
obj_np = self.controller.id_to_object_np.get(gid) obj_np = self.controller.id_to_object_np.get(gid)
pick_np = self.controller.id_to_pick_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(): if obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty():
@ -2112,7 +2130,8 @@ class SSBOEditor:
def _update_outline_for_selection(self): def _update_outline_for_selection(self):
if not self._outline_manager: if not self._outline_manager:
return return
if not self.controller or not self.selected_ids: transform_ids = self.get_selection_transform_ids()
if not self.controller or not transform_ids:
self._outline_manager.clear() self._outline_manager.clear()
return return
@ -2126,7 +2145,7 @@ class SSBOEditor:
targets = [] targets = []
target_limit = max(1, int(getattr(self._outline_manager, "max_targets", 64))) target_limit = max(1, int(getattr(self._outline_manager, "max_targets", 64)))
for gid in self.selected_ids: for gid in transform_ids:
obj_np = self.controller.id_to_object_np.get(gid) obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty(): if obj_np and not obj_np.is_empty():
targets.append(obj_np) targets.append(obj_np)
@ -2149,6 +2168,11 @@ class SSBOEditor:
def has_active_selection(self): def has_active_selection(self):
return bool(self.controller and self.selected_name is not None) return bool(self.controller and self.selected_name is not None)
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): def _is_root_selection(self):
return bool( return bool(
self.controller and self.controller and
@ -2161,6 +2185,7 @@ class SSBOEditor:
return None return None
has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {}) has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {})
transform_ids = self.get_selection_transform_ids()
if self._is_root_selection(): if self._is_root_selection():
return self.model if self._node_is_valid(self.model) else None return self.model if self._node_is_valid(self.model) else None
@ -2168,7 +2193,7 @@ class SSBOEditor:
if not has_dynamic_objects: if not has_dynamic_objects:
return self.model if self._node_is_valid(self.model) else None return self.model if self._node_is_valid(self.model) else None
if len(self.selected_ids) > 1: if len(transform_ids) > 1:
proxy = getattr(self, "_group_proxy", None) proxy = getattr(self, "_group_proxy", None)
if proxy and self._node_is_valid(proxy): if proxy and self._node_is_valid(proxy):
# Apply the original first node's material/tags to proxy transparently? # Apply the original first node's material/tags to proxy transparently?
@ -2182,15 +2207,36 @@ class SSBOEditor:
return None return None
def get_selection_runtime_material_node(self):
"""Return the runtime node that material editing should target."""
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): def get_selection_summary(self):
if not self.controller or self.selected_name is None: if not self.controller or self.selected_name is None:
return None return None
return { return {
"key": self.selected_name, "key": self.selected_name,
"display_name": self.controller.display_names.get(self.selected_name, self.selected_name), "display_name": self.controller.display_names.get(self.selected_name, self.selected_name),
"object_count": len(self.selected_ids), "object_count": len(self.get_selection_transform_ids()),
"is_root": self._is_root_selection(), "is_root": self._is_root_selection(),
"is_group": len(self.selected_ids) > 1 and not self._is_root_selection(), "is_group": len(self.get_selection_transform_ids()) > 1 and not self._is_root_selection(),
} }
def get_selection_key(self): def get_selection_key(self):
@ -2298,6 +2344,7 @@ class SSBOEditor:
self._cleanup_group_proxy() self._cleanup_group_proxy()
self.selected_name = None self.selected_name = None
self.selected_ids = [] self.selected_ids = []
self.transform_ids = []
if self._outline_manager: if self._outline_manager:
self._outline_manager.clear() self._outline_manager.clear()
if self.controller: if self.controller:
@ -2355,7 +2402,12 @@ class SSBOEditor:
self._reset_pick_sync_cache() self._reset_pick_sync_cache()
self.selected_name = key self.selected_name = key
self.selected_ids = self.controller.name_to_ids.get(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 {}) has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {})
is_root_selection = ( is_root_selection = (
self.controller and self.controller and
@ -2367,7 +2419,7 @@ class SSBOEditor:
root_key = getattr(self.controller, "tree_root_key", None) root_key = getattr(self.controller, "tree_root_key", None)
root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {} root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {}
top_level_children = set(root_node.get("children", []) or []) top_level_children = set(root_node.get("children", []) or [])
is_top_level_heavy_selection = key in top_level_children and len(self.selected_ids) >= 256 is_top_level_heavy_selection = key in top_level_children and len(self.transform_ids) >= 256
except Exception: except Exception:
is_top_level_heavy_selection = False is_top_level_heavy_selection = False
if sync_world_selection: if sync_world_selection:
@ -2388,17 +2440,17 @@ class SSBOEditor:
self._stop_pick_sync_task() self._stop_pick_sync_task()
return return
self.controller.set_active_ids(self.selected_ids) self.controller.set_active_ids(self.transform_ids)
self._update_outline_for_selection() self._update_outline_for_selection()
if not self._transform_gizmo or not self.selected_ids: if not self._transform_gizmo or not self.transform_ids:
if self._transform_gizmo: if self._transform_gizmo:
self._transform_gizmo.detach() self._transform_gizmo.detach()
return return
if len(self.selected_ids) == 1: if len(self.transform_ids) == 1:
# Single object: attach gizmo directly # Single object: attach gizmo directly
obj_np = self.controller.id_to_object_np.get(self.selected_ids[0]) obj_np = self.controller.id_to_object_np.get(self.transform_ids[0])
if obj_np and not obj_np.is_empty(): if obj_np and not obj_np.is_empty():
self._transform_gizmo.attach(obj_np) self._transform_gizmo.attach(obj_np)
self._start_pick_sync_task() self._start_pick_sync_task()
@ -2416,7 +2468,7 @@ class SSBOEditor:
pass pass
center = Vec3(0, 0, 0) center = Vec3(0, 0, 0)
valid = [] valid = []
for gid in self.selected_ids: for gid in self.transform_ids:
obj_np = self.controller.id_to_object_np.get(gid) obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty(): if obj_np and not obj_np.is_empty():
center += obj_np.get_pos(self.base.render) center += obj_np.get_pos(self.base.render)
@ -2494,8 +2546,9 @@ class SSBOEditor:
self.filtered_nodes = rows self.filtered_nodes = rows
def focus_on_selected(self): def focus_on_selected(self):
if self.selected_name and self.selected_ids: transform_ids = self.get_selection_transform_ids()
first_id = self.selected_ids[0] if self.selected_name and transform_ids:
first_id = transform_ids[0]
pos = self.controller.get_world_pos(first_id) pos = self.controller.get_world_pos(first_id)
dist = 100 dist = 100
self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5) self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5)
@ -2552,7 +2605,8 @@ class SSBOEditor:
if io.want_capture_keyboard: return task.cont if io.want_capture_keyboard: return task.cont
if self.selected_ids and self.controller: transform_ids = self.get_selection_transform_ids()
if transform_ids and self.controller:
speed = 50 * dt speed = 50 * dt
acc = Vec3(0, 0, 0) acc = Vec3(0, 0, 0)
if self.keys.get('arrow_up'): acc.z += speed if self.keys.get('arrow_up'): acc.z += speed
@ -2574,7 +2628,7 @@ class SSBOEditor:
self.model.set_pos(next_pos) self.model.set_pos(next_pos)
self._sync_pick_root_transform() self._sync_pick_root_transform()
else: else:
for idx in self.selected_ids: for idx in transform_ids:
self.controller.move_object(idx, acc) self.controller.move_object(idx, acc)
return task.cont return task.cont

View File

@ -355,7 +355,7 @@ class MainApp(ShowBase):
imported_node_key = str(model_component.get("imported_node_key", "") or node.get("imported_node_key", "") or "") imported_node_key = str(model_component.get("imported_node_key", "") or node.get("imported_node_key", "") or "")
if asset_guid: if asset_guid:
loaded_np = self._load_runtime_asset_node(asset_guid, imported_node_key, node_name) loaded_np = self._load_runtime_asset_node(asset_guid, imported_node_key, node_name, node)
rebuilt_np = loaded_np if loaded_np and not loaded_np.isEmpty() else parent_np.attachNewNode(node_name) rebuilt_np = loaded_np if loaded_np and not loaded_np.isEmpty() else parent_np.attachNewNode(node_name)
if rebuilt_np.getParent() != parent_np: if rebuilt_np.getParent() != parent_np:
rebuilt_np.reparentTo(parent_np) rebuilt_np.reparentTo(parent_np)
@ -394,27 +394,55 @@ class MainApp(ShowBase):
return rebuilt_np return rebuilt_np
def _load_runtime_asset_node(self, asset_guid, imported_node_key="", node_name=""): def _load_runtime_asset_node(self, asset_guid, imported_node_key="", node_name="", node_data=None):
asset_record = dict(self._asset_index.get(str(asset_guid or ""), {}) or {}) asset_record = dict(self._asset_index.get(str(asset_guid or ""), {}) or {})
if not asset_record: if not asset_record:
return None return None
candidate_paths = [] candidate_paths = []
imported_cache = dict(asset_record.get("imported_cache", {}) or {}) node_data = dict(node_data or {})
imported_model_rel = str(imported_cache.get("model_bam", "") or "").replace("\\", "/").strip() components = dict(node_data.get("components", {}) or {})
if imported_model_rel: metadata_component = dict(components.get("metadata", {}) or {})
candidate_paths.append(os.path.join(DATA_ROOT, imported_model_rel.replace("/", os.sep))) node_tags = dict(node_data.get("tags", {}) or {})
has_animations = str(
metadata_component.get(
"has_animations",
metadata_component.get(
"saved_has_animations",
node_tags.get("has_animations", node_tags.get("saved_has_animations", "")),
),
)
or ""
).lower() == "true"
can_create_actor = str(
metadata_component.get(
"can_create_actor_from_memory",
metadata_component.get(
"saved_can_create_actor_from_memory",
node_tags.get(
"can_create_actor_from_memory",
node_tags.get("saved_can_create_actor_from_memory", ""),
),
),
)
or ""
).lower() == "true"
asset_dir = os.path.join(DATA_ROOT, "assets", asset_guid) asset_dir = os.path.join(DATA_ROOT, "assets", asset_guid)
imported_model_path = os.path.join(asset_dir, "imported", "model.bam")
if os.path.exists(imported_model_path):
candidate_paths.append(imported_model_path)
asset_path = str(asset_record.get("asset_path", "") or "").replace("\\", "/").strip() asset_path = str(asset_record.get("asset_path", "") or "").replace("\\", "/").strip()
if asset_path: if asset_path:
candidate_paths.append(os.path.join(DATA_ROOT, asset_path.replace("/", os.sep))) candidate_paths.append(os.path.join(DATA_ROOT, asset_path.replace("/", os.sep)))
candidate_paths.append(os.path.join(asset_dir, os.path.basename(asset_path))) candidate_paths.append(os.path.join(asset_dir, os.path.basename(asset_path)))
imported_cache = dict(asset_record.get("imported_cache", {}) or {})
imported_model_rel = str(imported_cache.get("model_bam", "") or "").replace("\\", "/").strip()
if imported_model_rel and not (has_animations or can_create_actor):
candidate_paths.append(os.path.join(DATA_ROOT, imported_model_rel.replace("/", os.sep)))
imported_model_path = os.path.join(asset_dir, "imported", "model.bam")
if os.path.exists(imported_model_path) and not (has_animations or can_create_actor):
candidate_paths.append(imported_model_path)
for candidate_path in candidate_paths: for candidate_path in candidate_paths:
if not candidate_path or not os.path.exists(candidate_path): if not candidate_path or not os.path.exists(candidate_path):
continue continue

View File

@ -948,7 +948,7 @@ class AnimationTools:
except Exception: except Exception:
has_animation_nodes = False has_animation_nodes = False
def _try_memory_fallback(): def _try_memory_fallback(prefer_scene_proxy=False):
def _collect_autobind_source_candidates(): def _collect_autobind_source_candidates():
candidates = [] candidates = []
@ -1032,6 +1032,22 @@ class AnimationTools:
can_create_from_memory = True can_create_from_memory = True
if can_create_from_memory: if can_create_from_memory:
autobind_candidates = _collect_autobind_source_candidates()
if prefer_scene_proxy:
for source_node in autobind_candidates:
mem_proxy = _try_create_autobind_proxy(
source_node,
f"内存模型({source_node.getName()})",
owns_node=False
)
if mem_proxy:
self._actor_cache[owner_model] = mem_proxy
owner_model.setTag("has_animations", "true")
if source_node != owner_model:
print(f"[Actor加载] 使用可见节点作为动画源: {owner_model.getName()} -> {source_node.getName()}")
return mem_proxy
# 不能直接 Actor(owner_model);会污染当前场景节点,导致播放后模型消失/选择失效。 # 不能直接 Actor(owner_model);会污染当前场景节点,导致播放后模型消失/选择失效。
# 先用副本创建真实 Actor只有失败时才退回 autoBind 代理。 # 先用副本创建真实 Actor只有失败时才退回 autoBind 代理。
clone_parent = self._get_owner_parent_node(owner_model) clone_parent = self._get_owner_parent_node(owner_model)
@ -1055,7 +1071,7 @@ class AnimationTools:
pass pass
# Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源 # Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源
for source_node in _collect_autobind_source_candidates(): for source_node in autobind_candidates:
mem_proxy = _try_create_autobind_proxy( mem_proxy = _try_create_autobind_proxy(
source_node, source_node,
f"内存模型({source_node.getName()})", f"内存模型({source_node.getName()})",
@ -1084,9 +1100,25 @@ class AnimationTools:
filepath = owner_model.getTag("original_path") filepath = owner_model.getTag("original_path")
print(f"[Actor加载调试] 获取到的 filepath: '{filepath}'") print(f"[Actor加载调试] 获取到的 filepath: '{filepath}'")
model_ext = str(filepath).lower() if filepath else ""
try:
character_count = owner_model.findAllMatches("**/+Character").getNumPaths()
except Exception:
character_count = 0
prefer_scene_proxy = (
(model_ext.endswith(".glb") or model_ext.endswith(".gltf")) and
character_count > 1
)
if not filepath: if not filepath:
print(f"[Actor加载调试] filepath为空触发 _try_memory_fallback()") print(f"[Actor加载调试] filepath为空触发 _try_memory_fallback()")
return _try_memory_fallback() return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy)
if prefer_scene_proxy:
memory_actor = _try_memory_fallback(prefer_scene_proxy=True)
if memory_actor:
print(f"[Actor加载] {owner_model.getName()} 使用场景内 autoBind 代理优先路径")
return memory_actor
# 针对Actor加载必须使用Panda3D规范的Unix风格路径否则Windows绝对路径会导致加载彻底崩溃并返回空节点 # 针对Actor加载必须使用Panda3D规范的Unix风格路径否则Windows绝对路径会导致加载彻底崩溃并返回空节点
panda_specific_path = "" panda_specific_path = ""
@ -1172,7 +1204,7 @@ class AnimationTools:
pass pass
# 所有创建路径失败时由内存加载进行兜底 # 所有创建路径失败时由内存加载进行兜底
return _try_memory_fallback() return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy)
def _getModelFormat(self, origin_model): def _getModelFormat(self, origin_model):

View File

@ -41,6 +41,37 @@ class AppActions:
return node return node
def _model_file_has_animation(self, file_path):
"""Detect whether a model file contains animation-related structures."""
try:
if not file_path or not os.path.exists(file_path):
return False
loader = getattr(self, "loader", None)
if not loader:
return False
normalized_path = file_path
try:
from scene import util as scene_util
normalized_path = scene_util.normalize_model_path(file_path)
except Exception:
normalized_path = file_path
model = loader.loadModel(normalized_path)
if not model or model.isEmpty():
return False
try:
has_animation = (
model.findAllMatches("**/+Character").getNumPaths() > 0
or model.findAllMatches("**/+AnimBundleNode").getNumPaths() > 0
)
finally:
try:
model.removeNode()
except Exception:
pass
return bool(has_animation)
except Exception:
return False
def _toggle_hot_reload(self): def _toggle_hot_reload(self):
"""切换热重载状态""" """切换热重载状态"""
@ -1485,11 +1516,13 @@ class AppActions:
except Exception as e: except Exception as e:
print(f"项目资源导入失败,回退直接导入: {e}") print(f"项目资源导入失败,回退直接导入: {e}")
animated_model = bool(file_path and self._model_file_has_animation(file_path))
command_manager = getattr(self, 'command_manager', None) command_manager = getattr(self, 'command_manager', None)
if scene_package_import or not command_manager: if scene_package_import or not command_manager:
return self._import_model_for_runtime(file_path, scene_package_import=scene_package_import) return self._import_model_for_runtime(file_path, scene_package_import=scene_package_import)
if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None): if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None) and not animated_model:
return self._execute_ssbo_import_command(file_path) return self._execute_ssbo_import_command(file_path)
from core.Command_System import CreateNodeCommand from core.Command_System import CreateNodeCommand
@ -1507,31 +1540,44 @@ class AppActions:
SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager). SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager).
Legacy mode: load via SceneManager. Legacy mode: load via SceneManager.
""" """
if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None): animated_model = bool(file_path and self._model_file_has_animation(file_path))
try: if animated_model:
# Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes. prefer_scene_manager = True
if hasattr(self, 'selection') and self.selection: ssbo_editor = getattr(self, 'ssbo_editor', None)
try: if ssbo_editor and hasattr(ssbo_editor, "clear_selection"):
self.selection.clearSelection() try:
except Exception: ssbo_editor.clear_selection(sync_world_selection=False)
try: except Exception:
self.selection.updateSelection(None) pass
except Exception:
pass
self.ssbo_editor.load_model( if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None):
file_path, if animated_model:
keep_source_model=scene_package_import, print(f"[AnimationImport] 检测到动画模型跳过SSBO导入: {file_path}")
append=not scene_package_import, else:
scene_package_import=scene_package_import, try:
) # Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes.
return self._refresh_ssbo_runtime_import_bindings( if hasattr(self, 'selection') and self.selection:
file_path=file_path, try:
scene_package_import=scene_package_import, self.selection.clearSelection()
) except Exception:
except Exception as e: try:
print(f"[SSBO] load_model failed: {e}") self.selection.updateSelection(None)
return None except Exception:
pass
self.ssbo_editor.load_model(
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,
scene_package_import=scene_package_import,
)
except Exception as e:
print(f"[SSBO] load_model failed: {e}")
return None
# Legacy fallback # Legacy fallback
if hasattr(self, 'scene_manager') and self.scene_manager: if hasattr(self, 'scene_manager') and self.scene_manager:

View File

@ -647,17 +647,16 @@ class DialogPanels:
) )
self.style_manager.prepare_centered_dialog(460, 260) self.style_manager.prepare_centered_dialog(460, 260)
with imgui_ctx.begin("关于 EG", True, flags) as window: with imgui_ctx.begin("关于 MetaCore", True, flags) as window:
if not window.opened: if not window.opened:
self.show_about_dialog = False self.show_about_dialog = False
return return
current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) or "未打开项目" current_project_path = getattr(getattr(self, "project_manager", None), "current_project_path", None) or "未打开项目"
imgui.text("EG 编辑器") imgui.text("元泰引擎 MetaCore V1.0")
imgui.separator() imgui.separator()
imgui.text(f"Python: {platform.python_version()}")
imgui.text(f"平台: {platform.system()} {platform.release()}") imgui.text(f"平台: {platform.system()} {platform.release()}")
imgui.text(f"可执行文件: {os.path.basename(sys.executable)}") imgui.text("可执行文件: MetaCore.exe")
imgui.separator() imgui.separator()
imgui.text("当前项目:") imgui.text("当前项目:")
imgui.text_colored((0.7, 0.7, 0.7, 1.0), current_project_path) imgui.text_colored((0.7, 0.7, 0.7, 1.0), current_project_path)

View File

@ -439,7 +439,11 @@ class EditorPanelsLeftMixin:
return return
display = controller.display_names.get(key, key) display = controller.display_names.get(key, key)
obj_count = len(controller.name_to_ids.get(key, [])) preferred_ids_fn = getattr(controller, "get_preferred_selection_ids", None)
if callable(preferred_ids_fn):
obj_count = len(preferred_ids_fn(key))
else:
obj_count = len(controller.name_to_ids.get(key, []))
children = node_data["children"] children = node_data["children"]
is_selected = (getattr(ssbo_editor, "selected_name", None) == key) is_selected = (getattr(ssbo_editor, "selected_name", None) == key)
@ -484,7 +488,11 @@ class EditorPanelsLeftMixin:
return return
display = str(virtual_node.get("display_name", virtual_node.get("name", key)) or key) display = str(virtual_node.get("display_name", virtual_node.get("name", key)) or key)
obj_count = len(controller.name_to_ids.get(key, [])) preferred_ids_fn = getattr(controller, "get_preferred_selection_ids", None)
if callable(preferred_ids_fn):
obj_count = len(preferred_ids_fn(key))
else:
obj_count = len(controller.name_to_ids.get(key, []))
is_selected = (getattr(ssbo_editor, "selected_name", None) == key) is_selected = (getattr(ssbo_editor, "selected_name", None) == key)
children = list((virtual_node.get("children", {}) or {}).values()) children = list((virtual_node.get("children", {}) or {}).values())

View File

@ -235,10 +235,21 @@ class EditorPanelsRightMixin(
elif node_type == "光源": elif node_type == "光源":
self._draw_property_section("光源", lambda: self.app._draw_light_properties(node), default_open=True) self._draw_property_section("光源", lambda: self.app._draw_light_properties(node), default_open=True)
elif node_type == "模型": elif node_type == "模型":
has_animation = False
try:
if node.hasTag("has_animations"):
has_animation = node.getTag("has_animations").lower() == "true"
if not has_animation:
has_animation = node.getPythonTag("animation") is True
except Exception:
has_animation = False
self._draw_property_section("模型", lambda: self.app._draw_model_properties(node), default_open=True) self._draw_property_section("模型", lambda: self.app._draw_model_properties(node), default_open=True)
self._draw_property_section("动画", lambda: self.app._draw_animation_properties(node), default_open=False) self._draw_property_section("动画", lambda: self.app._draw_animation_properties(node), default_open=has_animation)
self._draw_property_section("材质", lambda: self.app._draw_appearance_properties(node), default_open=True) material_target = self.app._get_selection_material_node()
if not material_target or material_target.isEmpty():
material_target = node
self._draw_property_section("材质", lambda target=material_target: self.app._draw_appearance_properties(target), default_open=True)
self._draw_property_section("碰撞", lambda: self.app._draw_collision_properties(node), default_open=False) self._draw_property_section("碰撞", lambda: self.app._draw_collision_properties(node), default_open=False)
self._draw_property_section("操作", lambda: self.app._draw_property_actions(node), default_open=False) self._draw_property_section("操作", lambda: self.app._draw_property_actions(node), default_open=False)

View File

@ -8,11 +8,36 @@ class EditorPanelsRightMaterialMixin:
self._material_edit_sessions = {} self._material_edit_sessions = {}
return self._material_edit_sessions return self._material_edit_sessions
def _sync_material_panel_target(self, node):
"""Reset per-node material UI state when selection changes."""
target_key = None
try:
target_key = getattr(node, "this", None) or id(node)
except Exception:
target_key = id(node)
if getattr(self, "_material_panel_target_key", None) == target_key:
return
self._material_panel_target_key = target_key
self._material_edit_sessions = {}
property_helpers = getattr(self.app, "property_helpers", None)
ensure_unique_fn = getattr(property_helpers, "_ensure_unique_materials_for_node", None)
if callable(ensure_unique_fn):
ensure_unique_fn(node)
def _ensure_node_materials_are_editable(self, node): def _ensure_node_materials_are_editable(self, node):
self._sync_material_panel_target(node)
ensure_unique_fn = getattr(self.app, "_ensure_unique_materials_for_node", None) ensure_unique_fn = getattr(self.app, "_ensure_unique_materials_for_node", None)
if callable(ensure_unique_fn): if callable(ensure_unique_fn):
try: try:
materials = ensure_unique_fn(node) ensure_unique_fn(node)
except Exception:
pass
panel_materials_fn = getattr(self.app, "_get_panel_edit_materials_for_node", None)
if callable(panel_materials_fn):
try:
materials = panel_materials_fn(node)
if materials: if materials:
return materials return materials
except Exception: except Exception:
@ -110,11 +135,11 @@ class EditorPanelsRightMaterialMixin:
def apply_primary_color(color): def apply_primary_color(color):
for current_material in materials: for current_material in materials:
self.app._set_material_base_color(current_material, color) self.app._set_material_base_color(current_material, color)
self.app._apply_material_to_geom_states(node, current_material)
if self.app._get_material_surface_type(current_material) == 3: if self.app._get_material_surface_type(current_material) == 3:
self.app._set_material_opacity(node, current_material, color[3]) self.app._set_material_opacity(node, current_material, color[3])
else: else:
self.app._apply_material_surface_state(node, current_material) self.app._sync_material_node_runtime(node, current_material, refresh_ssbo_runtime=False)
self._refresh_ssbo_runtime_for_material_node(node)
def apply_surface_type(surface_type): def apply_surface_type(surface_type):
for current_material in materials: for current_material in materials:

View File

@ -50,6 +50,18 @@ class PanelDelegates:
return source_node return source_node
return self._get_selection_node() return self._get_selection_node()
def _get_selection_material_node(self):
"""Return the node that material editing should target."""
ssbo_editor = getattr(self, "ssbo_editor", None)
if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection():
source_node = getattr(ssbo_editor, "get_selection_source_material_node", lambda: None)()
if source_node and self._node_is_valid(source_node, require_attached=False):
return source_node
runtime_node = getattr(ssbo_editor, "get_selection_runtime_material_node", lambda: None)()
if runtime_node and self._node_is_valid(runtime_node, require_attached=False):
return runtime_node
return self._get_selection_source_node()
def _get_selection_key(self): def _get_selection_key(self):
ssbo_editor = getattr(self, "ssbo_editor", None) ssbo_editor = getattr(self, "ssbo_editor", None)
if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection(): if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection():
@ -339,6 +351,9 @@ class PanelDelegates:
def _apply_material_to_geom_states(self, *args, **kwargs): def _apply_material_to_geom_states(self, *args, **kwargs):
return self.property_helpers._apply_material_to_geom_states(*args, **kwargs) return self.property_helpers._apply_material_to_geom_states(*args, **kwargs)
def _sync_material_node_runtime(self, *args, **kwargs):
return self.property_helpers._sync_material_node_runtime(*args, **kwargs)
def _update_material_base_color(self, *args, **kwargs): def _update_material_base_color(self, *args, **kwargs):
return self.property_helpers._update_material_base_color(*args, **kwargs) return self.property_helpers._update_material_base_color(*args, **kwargs)

View File

@ -1101,6 +1101,7 @@ class PropertyHelpers:
self._apply_material_to_geom_states(node, material) self._apply_material_to_geom_states(node, material)
self._apply_material_surface_state(node, material) self._apply_material_surface_state(node, material)
self._persist_material_tags(node, material)
self._clear_all_textures(node) self._clear_all_textures(node)
texture_tags = node_state.get("textures", {}) or {} texture_tags = node_state.get("textures", {}) or {}
@ -1148,31 +1149,68 @@ class PropertyHelpers:
if not node or node.isEmpty() or material is None: if not node or node.isEmpty() or material is None:
return return
target_geom_paths = self._get_geom_paths_for_material(node, material) target_geom_paths = []
if not target_geom_paths: force_subtree_override = False
direct_geom_target = self._is_geom_node_path(node)
try:
if node.hasMaterial():
node_material = node.getMaterial()
if self._get_material_identity_key(node_material) == self._get_material_identity_key(material):
force_subtree_override = True
except Exception:
force_subtree_override = False
if direct_geom_target:
target_geom_paths = [node]
elif force_subtree_override:
try: try:
if node.hasMaterial(): node.setMaterial(material, 1)
node.setMaterial(material, 1)
except Exception: except Exception:
pass pass
try: try:
target_geom_paths = [geom_path for geom_path in node.find_all_matches("**/+GeomNode")] target_geom_paths = [geom_path for geom_path in node.find_all_matches("**/+GeomNode")]
except Exception: except Exception:
target_geom_paths = [] target_geom_paths = []
else:
target_geom_paths = self._get_geom_paths_for_material(node, material)
if not target_geom_paths: if not target_geom_paths:
self._invalidate_material_render_cache() try:
return if node.hasMaterial():
node.setMaterial(material, 1)
except Exception:
pass
try:
target_geom_paths = [geom_path for geom_path in node.find_all_matches("**/+GeomNode")]
except Exception:
target_geom_paths = []
if not target_geom_paths:
self._invalidate_material_render_cache()
return
if direct_geom_target:
try:
if node.hasMaterial():
node.clearMaterial()
except Exception:
pass
try:
node.clearAttrib(MaterialAttrib.getClassType())
except Exception:
pass
for geom_path in target_geom_paths: for geom_path in target_geom_paths:
try: if not direct_geom_target:
geom_path.setMaterial(material, 1) try:
except Exception: geom_path.setMaterial(material, 1)
pass except Exception:
pass
try: try:
geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material))) geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material)))
except Exception: except Exception:
pass pass
geom_node = geom_path.node() geom_node = geom_path.node()
for i in range(geom_node.getNumGeoms()): for i in range(geom_node.getNumGeoms()):
@ -1189,6 +1227,8 @@ class PropertyHelpers:
"""If editing a SSBO source node, rebuild the runtime proxy immediately.""" """If editing a SSBO source node, rebuild the runtime proxy immediately."""
try: try:
ssbo_editor = getattr(self, "ssbo_editor", None) ssbo_editor = getattr(self, "ssbo_editor", None)
if ssbo_editor is None:
ssbo_editor = getattr(getattr(self, "app", None), "ssbo_editor", None)
if not ssbo_editor or not node or node.isEmpty(): if not ssbo_editor or not node or node.isEmpty():
return return
if not hasattr(ssbo_editor, "is_source_tree_node") or not ssbo_editor.is_source_tree_node(node): if not hasattr(ssbo_editor, "is_source_tree_node") or not ssbo_editor.is_source_tree_node(node):
@ -1210,6 +1250,7 @@ class PropertyHelpers:
continue continue
self._apply_material_to_geom_states(node, current_material) self._apply_material_to_geom_states(node, current_material)
self._apply_material_surface_state(node, current_material) self._apply_material_surface_state(node, current_material)
self._persist_material_tags(node, current_material)
refresh_pipeline = getattr(self, "_refresh_pipeline_material_mode", None) refresh_pipeline = getattr(self, "_refresh_pipeline_material_mode", None)
if callable(refresh_pipeline): if callable(refresh_pipeline):
try: try:
@ -1222,21 +1263,72 @@ class PropertyHelpers:
except Exception as e: except Exception as e:
print(f"同步材质显示状态失败: {e}") print(f"同步材质显示状态失败: {e}")
def _persist_material_tags(self, node, material):
"""Mirror editable material values into node tags for project serialization."""
try:
if not node or node.isEmpty() or material is None:
return
emission = self._get_material_emission(material)
if emission is not None:
node.setTag("material_emission", str(tuple(float(v) for v in emission)))
base_color = self._get_material_base_color(material)
if base_color is not None:
node.setTag("material_basecolor", str(tuple(float(v) for v in base_color)))
scalar_values = {
"material_roughness": float(material.roughness) if hasattr(material, "roughness") and material.roughness is not None else None,
"material_metallic": float(material.metallic) if hasattr(material, "metallic") and material.metallic is not None else None,
"material_ior": float(material.refractive_index) if hasattr(material, "refractive_index") and material.refractive_index is not None else None,
}
for tag_name, value in scalar_values.items():
if value is not None:
node.setTag(tag_name, str(float(value)))
except Exception:
pass
def _get_node_materials(self, node): def _get_node_materials(self, node):
"""Return the editable materials currently used by a node.""" """Return the editable materials currently used by a node."""
if not node or node.isEmpty(): if not node or node.isEmpty():
return [] return []
materials = []
try: try:
if node.hasMaterial(): if node.hasMaterial():
return [node.getMaterial()] node_material = node.getMaterial()
if node_material is not None:
materials.append(node_material)
except Exception: except Exception:
pass pass
try: try:
materials = list(node.find_all_materials()) materials.extend(list(node.find_all_materials()))
except Exception: except Exception:
materials = [] pass
# `find_all_materials()` can miss effective inherited material state on some
# wrapper/transform nodes, so inspect renderable children as a fallback.
try:
from panda3d.core import MaterialAttrib
for geom_path in node.find_all_matches("**/+GeomNode"):
try:
if geom_path.hasMaterial():
geom_material = geom_path.getMaterial()
if geom_material is not None:
materials.append(geom_material)
net_state = geom_path.getNetState()
if net_state.hasAttrib(MaterialAttrib):
material_attrib = net_state.getAttrib(MaterialAttrib)
geom_material = material_attrib.getMaterial() if material_attrib else None
if geom_material is not None:
materials.append(geom_material)
except Exception:
continue
except Exception:
pass
unique_materials = [] unique_materials = []
seen_keys = set() seen_keys = set()
@ -1284,7 +1376,10 @@ class PropertyHelpers:
if not node or node.isEmpty(): if not node or node.isEmpty():
return [] return []
materials = self._get_node_materials(node) if self._is_geom_node_path(node):
materials = self._get_panel_edit_materials_for_node(node)
else:
materials = self._get_node_materials(node)
if not materials: if not materials:
return [] return []
@ -1298,8 +1393,12 @@ class PropertyHelpers:
stored_signature = tuple(node.getPythonTag("_editable_material_signature")) stored_signature = tuple(node.getPythonTag("_editable_material_signature"))
except Exception: except Exception:
stored_signature = None stored_signature = None
try:
stored_isolated = bool(node.getPythonTag("_editable_material_isolated"))
except Exception:
stored_isolated = False
if stored_signature == current_signature: if stored_signature == current_signature and stored_isolated:
return materials return materials
node_material_key = None node_material_key = None
@ -1311,6 +1410,7 @@ class PropertyHelpers:
changed = False changed = False
cloned_materials = [] cloned_materials = []
direct_geom_target = self._is_geom_node_path(node)
for material in materials: for material in materials:
if material is None: if material is None:
@ -1324,31 +1424,54 @@ class PropertyHelpers:
continue continue
try: try:
if node_material_key is not None and source_key == node_material_key: if node_material_key is not None and source_key == node_material_key and not direct_geom_target:
node.setMaterial(cloned_material, 1) node.setMaterial(cloned_material, 1)
changed = True changed = True
except Exception: except Exception:
pass pass
geom_paths = self._get_geom_paths_for_material(node, material) if direct_geom_target:
geom_paths = [node]
try:
if node.hasMaterial():
node.clearMaterial()
except Exception:
pass
try:
node.clearAttrib(MaterialAttrib.getClassType())
except Exception:
pass
else:
geom_paths = self._get_geom_paths_for_material(node, material)
if not geom_paths and node_material_key is None: if not geom_paths and node_material_key is None:
try: try:
node.setMaterial(cloned_material, 1) has_geom_descendants = bool(node.find_all_matches("**/+GeomNode"))
changed = True
except Exception: except Exception:
pass has_geom_descendants = False
# Only fall back to applying on the wrapper node when it is the
# actual render target. For transform parents, a blind node-level
# override creates an editable material that does not affect the
# visible child geom states.
if not has_geom_descendants:
try:
node.setMaterial(cloned_material, 1)
changed = True
except Exception:
pass
for geom_path in geom_paths: for geom_path in geom_paths:
try: if not direct_geom_target:
geom_path.setMaterial(cloned_material, 1) try:
changed = True geom_path.setMaterial(cloned_material, 1)
except Exception: changed = True
pass except Exception:
pass
try: try:
geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(cloned_material))) geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(cloned_material)))
except Exception: except Exception:
pass pass
try: try:
geom_node = geom_path.node() geom_node = geom_path.node()
@ -1378,6 +1501,10 @@ class PropertyHelpers:
node.setPythonTag("_editable_material_signature", latest_signature) node.setPythonTag("_editable_material_signature", latest_signature)
except Exception: except Exception:
pass pass
try:
node.setPythonTag("_editable_material_isolated", bool(changed))
except Exception:
pass
return latest_materials return latest_materials
except Exception as e: except Exception as e:
@ -1390,6 +1517,27 @@ class PropertyHelpers:
except Exception: except Exception:
return id(material) return id(material)
def _is_geom_node_path(self, node):
"""Return whether the given NodePath directly wraps a GeomNode."""
try:
if not node or node.isEmpty():
return False
panda_node = node.node()
if panda_node is None:
return False
if hasattr(panda_node, "isGeomNode"):
try:
return bool(panda_node.isGeomNode())
except Exception:
pass
from panda3d.core import GeomNode
try:
return panda_node.is_of_type(GeomNode.get_class_type())
except Exception:
return isinstance(panda_node, GeomNode)
except Exception:
return False
def _geom_uses_material(self, geom_path, material): def _geom_uses_material(self, geom_path, material):
try: try:
from panda3d.core import MaterialAttrib from panda3d.core import MaterialAttrib
@ -1474,6 +1622,34 @@ class PropertyHelpers:
print(f"创建默认材质失败: {e}") print(f"创建默认材质失败: {e}")
return None return None
def _get_panel_edit_materials_for_node(self, node):
"""Return the material instances the property panel should edit for this node.
Parent/group nodes should edit a single node-level override material instead
of mutating every descendant material individually.
"""
if not node or node.isEmpty():
return []
if self._is_geom_node_path(node):
material = self._get_renderable_node_material(node)
if material is None:
material = self._ensure_material_for_node(node)
return [material] if material is not None else []
try:
geom_descendants = list(node.find_all_matches("**/+GeomNode"))
except Exception:
geom_descendants = []
if node.hasMaterial():
return self._get_node_materials(node)
if geom_descendants:
return []
return self._get_node_materials(node)
def _get_material_surface_type(self, material): def _get_material_surface_type(self, material):
"""Return the RenderPipeline shading model value used by this material.""" """Return the RenderPipeline shading model value used by this material."""
try: try:
@ -1800,6 +1976,8 @@ class PropertyHelpers:
try: try:
if not node or node.isEmpty(): if not node or node.isEmpty():
return [] return []
if self._is_geom_node_path(node):
return [node]
renderable_nodes = [match for match in node.find_all_matches("**/+GeomNode")] renderable_nodes = [match for match in node.find_all_matches("**/+GeomNode")]
if renderable_nodes: if renderable_nodes:
return renderable_nodes return renderable_nodes
@ -1899,13 +2077,28 @@ class PropertyHelpers:
opacity = self._get_material_opacity(material) if is_transparent else 1.0 opacity = self._get_material_opacity(material) if is_transparent else 1.0
base_color = self._get_material_transparent_base_color(material) base_color = self._get_material_transparent_base_color(material)
target_nodes = self._get_geom_paths_for_material(node, material) force_subtree_override = False
if not target_nodes: direct_geom_target = self._is_geom_node_path(node)
try:
if node.hasMaterial():
node_material = node.getMaterial()
if self._get_material_identity_key(node_material) == self._get_material_identity_key(material):
force_subtree_override = True
except Exception:
force_subtree_override = False
if direct_geom_target:
target_nodes = [node]
elif force_subtree_override:
target_nodes = self._iter_material_state_nodes(node) target_nodes = self._iter_material_state_nodes(node)
else:
target_nodes = self._get_geom_paths_for_material(node, material)
if not target_nodes:
target_nodes = self._iter_material_state_nodes(node)
for target_node in target_nodes: for target_node in target_nodes:
target_material = material target_material = material
if target_material is not None: if target_material is not None and not direct_geom_target:
try: try:
target_node.setMaterial(target_material, 1) target_node.setMaterial(target_material, 1)
except Exception: except Exception: