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",
"import_settings": {},
"dependency_guids": [],
"updated_at": "2026-03-19 17:17:48",
"updated_at": "2026-03-23 14:37:54",
"imported_cache": {
"root": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d",
"model_bam": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/model.bam",
@ -18,9 +18,85 @@
"materials": "Library/Imported/90ece77e67b54ccda38d2de71cb4694d/materials.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": {
"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_type": "model",
"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("", "window-type onscreen")
loadPrcFileData("", "window-title MetaCore")
loadPrcFileData("", f"win-size {width} {height}")
loadPrcFileData("", "win-fixed-size #f") # 允许窗口调整大小
@ -81,6 +82,13 @@ class CoreWorld(ShowBase):
# 初始化 ShowBase
ShowBase.__init__(self)
try:
props = WindowProperties()
props.setTitle("MetaCore")
self.win.requestProperties(props)
except Exception:
pass
# 创建渲染管线
self.render_pipeline.create(self)
install_editor_tag_state_manager(self.render_pipeline, self)

View File

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

View File

@ -1008,7 +1008,7 @@ class ProjectManager:
load_lui_fn(temp_stub)
except Exception:
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):
nodes = list(scene_description.get("nodes", []) or [])
@ -1089,18 +1089,176 @@ class ProjectManager:
target_np.setTag("file", os.path.basename(asset_abs_path))
if imported_node_key:
target_np.setTag("imported_node_key", imported_node_key)
target_np.setTag("is_model_root", "1")
target_np.setTag("is_scene_element", "1")
if asset_guid:
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)))
if runtime_interactive:
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 [])
if scripts:
target_np.setTag("has_scripts", "true")
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):
if not getattr(self.world, "use_ssbo_mouse_picking", False):
return False
@ -1109,6 +1267,8 @@ class ProjectManager:
if not ssbo_editor:
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)
if not top_level_asset_nodes:
return False
@ -1133,6 +1293,33 @@ class ProjectManager:
if not os.path.exists(asset_abs):
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:
imported_root = ssbo_editor.load_model(
asset_abs,
@ -1148,7 +1335,15 @@ class ProjectManager:
if target_root is None:
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
if not loaded_any:
@ -1494,6 +1689,14 @@ class ProjectManager:
print("错误: 请先创建或打开一个项目!")
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
ensure_project_directories(ProjectLayout(project_path))
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 []):
try:
if tree_widget:
tree_widget.delete_item(model)
elif model and not model.isEmpty():
try:
tree_widget.delete_item(model)
except Exception:
pass
if model and not model.isEmpty():
model.removeNode()
except Exception:
pass
@ -2225,8 +2431,11 @@ class ProjectManager:
for light_node in collection:
try:
if tree_widget:
tree_widget.delete_item(light_node)
elif light_node and not light_node.isEmpty():
try:
tree_widget.delete_item(light_node)
except Exception:
pass
if light_node and not light_node.isEmpty():
light_node.removeNode()
except Exception:
pass
@ -2237,8 +2446,11 @@ class ProjectManager:
for terrain in terrains:
try:
if tree_widget:
tree_widget.delete_item(terrain)
elif terrain and not terrain.isEmpty():
try:
tree_widget.delete_item(terrain)
except Exception:
pass
if terrain and not terrain.isEmpty():
terrain.removeNode()
except Exception:
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:
return {
metadata = {
"node_class": node.node().getClassType().getName() if node.node() else "",
"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):
@ -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:
if not node:
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:
return True
if scripts:
@ -447,6 +461,37 @@ def build_runtime_scene(scene_description: dict):
for node in nodes:
if not node.get("asset_guid"):
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"):
interactive_node_ids.append(node.get("node_id", ""))
interactive_model_names.append(node.get("name", ""))

View File

@ -242,6 +242,20 @@ class ObjectController:
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):
if not vdata.has_column("color"):
new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())

View File

@ -79,6 +79,7 @@ class SSBOEditor:
# Internal State
self.selected_name = None
self.selected_ids = []
self.transform_ids = []
self.search_text = ""
self.last_search_text = None
self.filtered_nodes = []
@ -666,22 +667,37 @@ class SSBOEditor:
if not self._node_is_valid(source_node):
continue
runtime_snapshot = None
if callable(capture_snapshot_fn):
try:
runtime_snapshot = capture_snapshot_fn(obj_np)
except Exception:
runtime_snapshot = None
source_snapshot = None
if callable(capture_snapshot_fn):
try:
source_snapshot = capture_snapshot_fn(obj_np)
source_snapshot = capture_snapshot_fn(source_node)
except Exception:
source_snapshot = None
source_node_key = id(source_node)
entry = grouped_entries.get(source_node_key)
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"]:
grouped_entries[source_node_key] = {
"gid": gid,
"obj_np": obj_np,
"source_node": source_node,
"snapshot": source_snapshot,
"snapshot": chosen_snapshot,
"score": candidate_score,
}
@ -870,6 +886,7 @@ class SSBOEditor:
self.model = None
self.selected_name = None
self.selected_ids = []
self.transform_ids = []
self.last_import_tree_key = None
self.last_import_root_name = None
if not preserve_source_models:
@ -1957,13 +1974,14 @@ class SSBOEditor:
if not self.controller:
return
self._sync_pick_root_transform()
if not self.selected_ids:
transform_ids = self.get_selection_transform_ids()
if not transform_ids:
return
# Group selection can contain thousands of objects.
# Only resync when proxy transform has changed.
proxy = getattr(self, "_group_proxy", None)
if proxy and not proxy.is_empty() and len(self.selected_ids) > 1:
if proxy and not proxy.is_empty() and len(transform_ids) > 1:
proxy_world = proxy.get_mat(self.base.render)
if self._last_group_sync_mat and self._matrices_close(proxy_world, self._last_group_sync_mat):
return
@ -1971,8 +1989,8 @@ class SSBOEditor:
else:
self._last_group_sync_mat = None
if len(self.selected_ids) == 1:
gid = self.selected_ids[0]
if len(transform_ids) == 1:
gid = transform_ids[0]
obj_np = self.controller.id_to_object_np.get(gid)
pick_np = self.controller.id_to_pick_np.get(gid)
if not (obj_np and pick_np and not obj_np.is_empty() and not pick_np.is_empty()):
@ -1996,7 +2014,7 @@ class SSBOEditor:
self._last_single_sync_gid = 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)
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():
@ -2112,7 +2130,8 @@ class SSBOEditor:
def _update_outline_for_selection(self):
if not self._outline_manager:
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()
return
@ -2126,7 +2145,7 @@ class SSBOEditor:
targets = []
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)
if obj_np and not obj_np.is_empty():
targets.append(obj_np)
@ -2149,6 +2168,11 @@ class SSBOEditor:
def has_active_selection(self):
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):
return bool(
self.controller and
@ -2161,6 +2185,7 @@ class SSBOEditor:
return None
has_dynamic_objects = bool(getattr(self.controller, "id_to_object_np", {}) or {})
transform_ids = self.get_selection_transform_ids()
if self._is_root_selection():
return self.model if self._node_is_valid(self.model) else None
@ -2168,7 +2193,7 @@ class SSBOEditor:
if not has_dynamic_objects:
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)
if proxy and self._node_is_valid(proxy):
# Apply the original first node's material/tags to proxy transparently?
@ -2182,15 +2207,36 @@ class SSBOEditor:
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):
if not self.controller or self.selected_name is None:
return None
return {
"key": self.selected_name,
"display_name": self.controller.display_names.get(self.selected_name, self.selected_name),
"object_count": len(self.selected_ids),
"object_count": len(self.get_selection_transform_ids()),
"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):
@ -2298,6 +2344,7 @@ class SSBOEditor:
self._cleanup_group_proxy()
self.selected_name = None
self.selected_ids = []
self.transform_ids = []
if self._outline_manager:
self._outline_manager.clear()
if self.controller:
@ -2355,7 +2402,12 @@ class SSBOEditor:
self._reset_pick_sync_cache()
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 {})
is_root_selection = (
self.controller and
@ -2367,7 +2419,7 @@ class SSBOEditor:
root_key = getattr(self.controller, "tree_root_key", None)
root_node = self.controller.tree_nodes.get(root_key, {}) if root_key else {}
top_level_children = 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:
is_top_level_heavy_selection = False
if sync_world_selection:
@ -2388,17 +2440,17 @@ class SSBOEditor:
self._stop_pick_sync_task()
return
self.controller.set_active_ids(self.selected_ids)
self.controller.set_active_ids(self.transform_ids)
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:
self._transform_gizmo.detach()
return
if len(self.selected_ids) == 1:
if len(self.transform_ids) == 1:
# 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():
self._transform_gizmo.attach(obj_np)
self._start_pick_sync_task()
@ -2416,7 +2468,7 @@ class SSBOEditor:
pass
center = Vec3(0, 0, 0)
valid = []
for gid in self.selected_ids:
for gid in self.transform_ids:
obj_np = self.controller.id_to_object_np.get(gid)
if obj_np and not obj_np.is_empty():
center += obj_np.get_pos(self.base.render)
@ -2494,8 +2546,9 @@ class SSBOEditor:
self.filtered_nodes = rows
def focus_on_selected(self):
if self.selected_name and self.selected_ids:
first_id = self.selected_ids[0]
transform_ids = self.get_selection_transform_ids()
if self.selected_name and transform_ids:
first_id = transform_ids[0]
pos = self.controller.get_world_pos(first_id)
dist = 100
self.base.camera.set_pos(pos.x, pos.y - dist, pos.z + dist * 0.5)
@ -2552,7 +2605,8 @@ class SSBOEditor:
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
acc = Vec3(0, 0, 0)
if self.keys.get('arrow_up'): acc.z += speed
@ -2574,7 +2628,7 @@ class SSBOEditor:
self.model.set_pos(next_pos)
self._sync_pick_root_transform()
else:
for idx in self.selected_ids:
for idx in transform_ids:
self.controller.move_object(idx, acc)
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 "")
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)
if rebuilt_np.getParent() != parent_np:
rebuilt_np.reparentTo(parent_np)
@ -394,27 +394,55 @@ class MainApp(ShowBase):
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 {})
if not asset_record:
return None
candidate_paths = []
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:
candidate_paths.append(os.path.join(DATA_ROOT, imported_model_rel.replace("/", os.sep)))
node_data = dict(node_data or {})
components = dict(node_data.get("components", {}) or {})
metadata_component = dict(components.get("metadata", {}) or {})
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)
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()
if asset_path:
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)))
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:
if not candidate_path or not os.path.exists(candidate_path):
continue

View File

@ -948,7 +948,7 @@ class AnimationTools:
except Exception:
has_animation_nodes = False
def _try_memory_fallback():
def _try_memory_fallback(prefer_scene_proxy=False):
def _collect_autobind_source_candidates():
candidates = []
@ -1032,6 +1032,22 @@ class AnimationTools:
can_create_from_memory = True
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只有失败时才退回 autoBind 代理。
clone_parent = self._get_owner_parent_node(owner_model)
@ -1055,7 +1071,7 @@ class AnimationTools:
pass
# Actor 副本失败后,从多个候选节点中选择“带几何体”的 autoBind 源
for source_node in _collect_autobind_source_candidates():
for source_node in autobind_candidates:
mem_proxy = _try_create_autobind_proxy(
source_node,
f"内存模型({source_node.getName()})",
@ -1084,9 +1100,25 @@ class AnimationTools:
filepath = owner_model.getTag("original_path")
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:
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绝对路径会导致加载彻底崩溃并返回空节点
panda_specific_path = ""
@ -1172,7 +1204,7 @@ class AnimationTools:
pass
# 所有创建路径失败时由内存加载进行兜底
return _try_memory_fallback()
return _try_memory_fallback(prefer_scene_proxy=prefer_scene_proxy)
def _getModelFormat(self, origin_model):

View File

@ -41,6 +41,37 @@ class AppActions:
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):
"""切换热重载状态"""
@ -1485,11 +1516,13 @@ class AppActions:
except Exception as e:
print(f"项目资源导入失败,回退直接导入: {e}")
animated_model = bool(file_path and self._model_file_has_animation(file_path))
command_manager = getattr(self, 'command_manager', None)
if scene_package_import or not command_manager:
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)
from core.Command_System import CreateNodeCommand
@ -1507,31 +1540,44 @@ class AppActions:
SSBO mode: load via SSBOEditor always (regardless of prefer_scene_manager).
Legacy mode: load via SceneManager.
"""
if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None):
try:
# Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes.
if hasattr(self, 'selection') and self.selection:
try:
self.selection.clearSelection()
except Exception:
try:
self.selection.updateSelection(None)
except Exception:
pass
animated_model = bool(file_path and self._model_file_has_animation(file_path))
if animated_model:
prefer_scene_manager = True
ssbo_editor = getattr(self, 'ssbo_editor', None)
if ssbo_editor and hasattr(ssbo_editor, "clear_selection"):
try:
ssbo_editor.clear_selection(sync_world_selection=False)
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
if self.use_ssbo_mouse_picking and getattr(self, 'ssbo_editor', None):
if animated_model:
print(f"[AnimationImport] 检测到动画模型跳过SSBO导入: {file_path}")
else:
try:
# Clear selection/gizmo first to avoid dangling references to soon-to-be removed nodes.
if hasattr(self, 'selection') and self.selection:
try:
self.selection.clearSelection()
except Exception:
try:
self.selection.updateSelection(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
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)
with imgui_ctx.begin("关于 EG", True, flags) as window:
with imgui_ctx.begin("关于 MetaCore", True, flags) as window:
if not window.opened:
self.show_about_dialog = False
return
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.text(f"Python: {platform.python_version()}")
imgui.text(f"平台: {platform.system()} {platform.release()}")
imgui.text(f"可执行文件: {os.path.basename(sys.executable)}")
imgui.text("可执行文件: MetaCore.exe")
imgui.separator()
imgui.text("当前项目:")
imgui.text_colored((0.7, 0.7, 0.7, 1.0), current_project_path)

View File

@ -439,7 +439,11 @@ class EditorPanelsLeftMixin:
return
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"]
is_selected = (getattr(ssbo_editor, "selected_name", None) == key)
@ -484,7 +488,11 @@ class EditorPanelsLeftMixin:
return
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)
children = list((virtual_node.get("children", {}) or {}).values())

View File

@ -235,10 +235,21 @@ class EditorPanelsRightMixin(
elif node_type == "光源":
self._draw_property_section("光源", lambda: self.app._draw_light_properties(node), default_open=True)
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_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_property_actions(node), default_open=False)

View File

@ -8,11 +8,36 @@ class EditorPanelsRightMaterialMixin:
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):
self._sync_material_panel_target(node)
ensure_unique_fn = getattr(self.app, "_ensure_unique_materials_for_node", None)
if callable(ensure_unique_fn):
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:
return materials
except Exception:
@ -110,11 +135,11 @@ class EditorPanelsRightMaterialMixin:
def apply_primary_color(color):
for current_material in materials:
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:
self.app._set_material_opacity(node, current_material, color[3])
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):
for current_material in materials:

View File

@ -50,6 +50,18 @@ class PanelDelegates:
return source_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):
ssbo_editor = getattr(self, "ssbo_editor", None)
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):
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):
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_surface_state(node, material)
self._persist_material_tags(node, material)
self._clear_all_textures(node)
texture_tags = node_state.get("textures", {}) or {}
@ -1148,31 +1149,68 @@ class PropertyHelpers:
if not node or node.isEmpty() or material is None:
return
target_geom_paths = self._get_geom_paths_for_material(node, material)
if not target_geom_paths:
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:
if node.hasMaterial():
node.setMaterial(material, 1)
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 = []
else:
target_geom_paths = self._get_geom_paths_for_material(node, material)
if not target_geom_paths:
self._invalidate_material_render_cache()
return
try:
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:
try:
geom_path.setMaterial(material, 1)
except Exception:
pass
if not direct_geom_target:
try:
geom_path.setMaterial(material, 1)
except Exception:
pass
try:
geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material)))
except Exception:
pass
try:
geom_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(material)))
except Exception:
pass
geom_node = geom_path.node()
for i in range(geom_node.getNumGeoms()):
@ -1189,6 +1227,8 @@ class PropertyHelpers:
"""If editing a SSBO source node, rebuild the runtime proxy immediately."""
try:
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():
return
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
self._apply_material_to_geom_states(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)
if callable(refresh_pipeline):
try:
@ -1222,21 +1263,72 @@ class PropertyHelpers:
except Exception as 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):
"""Return the editable materials currently used by a node."""
if not node or node.isEmpty():
return []
materials = []
try:
if node.hasMaterial():
return [node.getMaterial()]
node_material = node.getMaterial()
if node_material is not None:
materials.append(node_material)
except Exception:
pass
try:
materials = list(node.find_all_materials())
materials.extend(list(node.find_all_materials()))
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 = []
seen_keys = set()
@ -1284,7 +1376,10 @@ class PropertyHelpers:
if not node or node.isEmpty():
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:
return []
@ -1298,8 +1393,12 @@ class PropertyHelpers:
stored_signature = tuple(node.getPythonTag("_editable_material_signature"))
except Exception:
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
node_material_key = None
@ -1311,6 +1410,7 @@ class PropertyHelpers:
changed = False
cloned_materials = []
direct_geom_target = self._is_geom_node_path(node)
for material in materials:
if material is None:
@ -1324,31 +1424,54 @@ class PropertyHelpers:
continue
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)
changed = True
except Exception:
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:
try:
node.setMaterial(cloned_material, 1)
changed = True
has_geom_descendants = bool(node.find_all_matches("**/+GeomNode"))
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:
try:
geom_path.setMaterial(cloned_material, 1)
changed = True
except Exception:
pass
if not direct_geom_target:
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_path.setState(geom_path.getState().setAttrib(MaterialAttrib.make(cloned_material)))
except Exception:
pass
try:
geom_node = geom_path.node()
@ -1378,6 +1501,10 @@ class PropertyHelpers:
node.setPythonTag("_editable_material_signature", latest_signature)
except Exception:
pass
try:
node.setPythonTag("_editable_material_isolated", bool(changed))
except Exception:
pass
return latest_materials
except Exception as e:
@ -1390,6 +1517,27 @@ class PropertyHelpers:
except Exception:
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):
try:
from panda3d.core import MaterialAttrib
@ -1474,6 +1622,34 @@ class PropertyHelpers:
print(f"创建默认材质失败: {e}")
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):
"""Return the RenderPipeline shading model value used by this material."""
try:
@ -1800,6 +1976,8 @@ class PropertyHelpers:
try:
if not node or node.isEmpty():
return []
if self._is_geom_node_path(node):
return [node]
renderable_nodes = [match for match in node.find_all_matches("**/+GeomNode")]
if renderable_nodes:
return renderable_nodes
@ -1899,13 +2077,28 @@ class PropertyHelpers:
opacity = self._get_material_opacity(material) if is_transparent else 1.0
base_color = self._get_material_transparent_base_color(material)
target_nodes = self._get_geom_paths_for_material(node, material)
if not target_nodes:
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_nodes = [node]
elif force_subtree_override:
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:
target_material = material
if target_material is not None:
if target_material is not None and not direct_geom_target:
try:
target_node.setMaterial(target_material, 1)
except Exception: