diff --git a/imgui.ini b/imgui.ini
index 59fef0d2..0c0c1e4f 100644
--- a/imgui.ini
+++ b/imgui.ini
@@ -37,9 +37,9 @@ DockId=0x00000007,0
[Window][属性面板]
Pos=1504,20
-Size=346,996
+Size=346,498
Collapsed=0
-DockId=0x00000002,0
+DockId=0x00000003,0
[Window][控制台]
Pos=341,617
@@ -48,9 +48,10 @@ Collapsed=0
DockId=0x00000006,1
[Window][脚本管理]
-Pos=1950,20
-Size=610,995
+Pos=1504,520
+Size=346,496
Collapsed=0
+DockId=0x00000004,0
[Window][中文显示测试]
Pos=60,60
@@ -152,7 +153,7 @@ Collapsed=0
Pos=2214,20
Size=346,1331
Collapsed=0
-DockId=0x00000002,1
+DockId=0x00000003,1
[Window][LUI测试控制面板]
Pos=6,10
@@ -233,5 +234,7 @@ DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1850,996 Split=X
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=0x00000006 Parent=0x00000008 SizeRef=2048,399 Selected=0x3A2E05C3
- DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=346,1012 Selected=0x5DB6FF37
+ DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=346,1012 Split=Y Selected=0x5DB6FF37
+ DockNode ID=0x00000003 Parent=0x00000002 SizeRef=346,498 Selected=0x5DB6FF37
+ DockNode ID=0x00000004 Parent=0x00000002 SizeRef=346,496 Selected=0x3188AB8D
diff --git a/main.py b/main.py
index cdf02a03..8276a90a 100644
--- a/main.py
+++ b/main.py
@@ -333,7 +333,7 @@ class MyWorld(PanelDelegates, CoreWorld):
"scene_tree": True,
"property": True,
"console": True,
- "script": not self.use_ssbo_mouse_picking,
+ "script": True,
"toolbar": True,
"resources": True,
"web": False,
diff --git a/project/webgl_packager.py b/project/webgl_packager.py
index 32ac74c0..ad6e4b27 100644
--- a/project/webgl_packager.py
+++ b/project/webgl_packager.py
@@ -1374,6 +1374,7 @@ class WebGLPackager:
out: List[Dict[str, Any]] = []
loader = getattr(script_manager, "loader", None)
find_script_file = getattr(loader, "find_script_file", None) if loader else None
+ build_script_reference = getattr(script_manager, "build_script_reference", None)
for component in script_components:
script_instance = getattr(component, "script_instance", None)
@@ -1395,7 +1396,17 @@ class WebGLPackager:
"name": script_name,
"enabled": bool(getattr(component, "enabled", True)),
}
- if script_file:
+ if callable(build_script_reference):
+ try:
+ reference = build_script_reference(script_name, script_file) or {}
+ except Exception:
+ reference = {}
+ if isinstance(reference, dict):
+ for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"):
+ value = str(reference.get(key, "") or "").strip()
+ if value:
+ entry[key] = value
+ elif script_file:
entry["file"] = script_file
params = self._serialize_script_instance_params(script_instance)
@@ -1436,9 +1447,10 @@ class WebGLPackager:
"name": script_name,
"enabled": bool(item.get("enabled", True)),
}
- script_file = str(item.get("file", "")).strip()
- if script_file:
- entry["file"] = script_file
+ for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"):
+ value = str(item.get(key, "") or "").strip()
+ if value:
+ entry[key] = value
params = item.get("params")
if isinstance(params, dict) and params:
entry["params"] = params
diff --git a/scripts/ColorChangerScript.py b/scripts/ColorChangerScript.py
index ac302f70..1579119c 100644
--- a/scripts/ColorChangerScript.py
+++ b/scripts/ColorChangerScript.py
@@ -1,160 +1,354 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-"""
-颜色变化脚本 - 让对象颜色产生循环变化
-"""
-
-from core.script_system import ScriptBase
-from panda3d.core import Vec4
-import math
-
-class ColorChangerScript(ScriptBase):
- """颜色变化脚本类"""
-
- def __init__(self):
- super().__init__()
-
- # 颜色参数
- self.color_speed = 1.0 # 颜色变化速度 (周期/秒)
- self.color_mode = "rainbow" # 颜色模式: "rainbow", "pulse", "fade", "strobe"
- self.base_color = Vec4(1, 1, 1, 1) # 基础颜色
- self.intensity = 1.0 # 颜色强度
-
- # 内部变量
- self.time_accumulator = 0.0 # 时间累积器
- self.original_color = None # 原始颜色
- self.is_changing = True # 是否正在变化
- self.strobe_state = False # 闪烁状态
-
- def start(self):
- """脚本开始时调用"""
- self.log("颜色变化脚本启动!")
- self.log(f"颜色参数: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}")
-
- # 记录原始颜色
- self.original_color = self.gameObject.getColor()
- self.log(f"原始颜色: {self.original_color}")
-
- def update(self, dt):
- """每帧更新"""
- if not self.is_changing:
- return
-
- # 累积时间
- self.time_accumulator += dt
-
- # 根据模式计算新颜色
- if self.color_mode == "rainbow":
- new_color = self._calculate_rainbow_color()
- elif self.color_mode == "pulse":
- new_color = self._calculate_pulse_color()
- elif self.color_mode == "fade":
- new_color = self._calculate_fade_color()
- elif self.color_mode == "strobe":
- new_color = self._calculate_strobe_color()
- else:
- new_color = self.base_color
-
- # 应用颜色
- self.gameObject.setColor(new_color)
-
- def _calculate_rainbow_color(self):
- """计算彩虹颜色"""
- # 使用HSV到RGB的转换创建彩虹效果
- hue = (self.time_accumulator * self.color_speed) % 1.0
-
- # 简单的HSV到RGB转换
- i = int(hue * 6.0)
- f = (hue * 6.0) - i
- p = 0.0
- q = 1.0 - f
- t = f
-
- if i % 6 == 0:
- r, g, b = 1.0, t, p
- elif i % 6 == 1:
- r, g, b = q, 1.0, p
- elif i % 6 == 2:
- r, g, b = p, 1.0, t
- elif i % 6 == 3:
- r, g, b = p, q, 1.0
- elif i % 6 == 4:
- r, g, b = t, p, 1.0
- else:
- r, g, b = 1.0, p, q
-
- return Vec4(r * self.intensity, g * self.intensity, b * self.intensity, 1.0)
-
- def _calculate_pulse_color(self):
- """计算脉冲颜色"""
- pulse = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0
- multiplier = pulse * self.intensity
- return Vec4(
- self.base_color.getX() * multiplier,
- self.base_color.getY() * multiplier,
- self.base_color.getZ() * multiplier,
- self.base_color.getW()
- )
-
- def _calculate_fade_color(self):
- """计算淡入淡出颜色"""
- fade = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0
- alpha = fade * self.intensity
- return Vec4(
- self.base_color.getX(),
- self.base_color.getY(),
- self.base_color.getZ(),
- alpha
- )
-
- def _calculate_strobe_color(self):
- """计算闪烁颜色"""
- # 根据时间间隔切换状态
- interval = 1.0 / (self.color_speed * 2) # 闪烁间隔
- if int(self.time_accumulator / interval) % 2 == 0:
- return Vec4(
- self.base_color.getX() * self.intensity,
- self.base_color.getY() * self.intensity,
- self.base_color.getZ() * self.intensity,
- self.base_color.getW()
- )
- else:
- return Vec4(0.1, 0.1, 0.1, self.base_color.getW()) # 暗色状态
-
- def set_color_parameters(self, speed=None, mode=None, base_color=None, intensity=None):
- """设置颜色参数"""
- if speed is not None:
- self.color_speed = speed
- if mode is not None and mode in ["rainbow", "pulse", "fade", "strobe"]:
- self.color_mode = mode
- if base_color is not None:
- self.base_color = base_color
- if intensity is not None:
- self.intensity = intensity
-
- self.log(f"颜色参数更新: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}")
-
- def toggle_color_change(self):
- """切换颜色变化状态"""
- self.is_changing = not self.is_changing
- status = "恢复" if self.is_changing else "暂停"
- self.log(f"颜色变化{status}")
-
- def reset_color(self):
- """重置到原始颜色"""
- if self.original_color:
- self.gameObject.setColor(self.original_color)
- self.time_accumulator = 0.0
- self.log("颜色已重置到原始值")
-
- def set_solid_color(self, r=1.0, g=1.0, b=1.0, a=1.0):
- """设置固定颜色"""
- color = Vec4(r, g, b, a)
- self.gameObject.setColor(color)
- self.base_color = color
- self.log(f"设置固定颜色: {color}")
-
- def on_destroy(self):
- """脚本销毁时调用"""
- self.log("颜色变化脚本停止")
\ No newline at end of file
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+颜色变化脚本 - 让对象颜色产生循环变化
+"""
+
+from core.script_system import ScriptBase
+from panda3d.core import TransparencyAttrib, Vec4
+import math
+
+
+class ColorChangerScript(ScriptBase):
+ """颜色变化脚本类"""
+
+ def __init__(self):
+ super().__init__()
+
+ # 颜色参数
+ self.color_speed = 1.0 # 颜色变化速度 (周期/秒)
+ self.color_mode = "rainbow" # 颜色模式: "rainbow", "pulse", "fade", "strobe"
+ self.base_color = Vec4(1, 1, 1, 1) # 基础颜色
+ self.intensity = 1.0 # 颜色强度
+
+ # 内部变量
+ self.time_accumulator = 0.0 # 时间累积器
+ self.original_color = None # 原始颜色
+ self.is_changing = True # 是否正在变化
+ self.strobe_state = False # 闪烁状态
+
+ # 渲染目标缓存(避免每帧重新遍历)
+ self._target_nodes = []
+ self._material_targets = []
+ self._property_helpers = None
+
+ def _is_node_valid(self, node):
+ if not node:
+ return False
+ try:
+ return not node.isEmpty()
+ except Exception:
+ try:
+ return not node.is_empty()
+ except Exception:
+ return False
+
+ def _collect_target_nodes(self):
+ """收集需要同步颜色的节点(根 + GeomNode 后代)"""
+ targets = []
+ if self._is_node_valid(self.gameObject):
+ targets.append(self.gameObject)
+ try:
+ for geom_np in self.gameObject.findAllMatches("**/+GeomNode"):
+ if self._is_node_valid(geom_np):
+ targets.append(geom_np)
+ except Exception:
+ pass
+ return targets
+
+ @staticmethod
+ def _clone_material(material):
+ try:
+ if hasattr(material, "makeCopy"):
+ return material.makeCopy()
+ except Exception:
+ pass
+ return material
+
+ @staticmethod
+ def _set_material_base_color(material, color):
+ color_vec = Vec4(color)
+ try:
+ if hasattr(material, "set_base_color"):
+ material.set_base_color(color_vec)
+ return
+ if hasattr(material, "setBaseColor"):
+ material.setBaseColor(color_vec)
+ return
+ if hasattr(material, "setDiffuse"):
+ material.setDiffuse(color_vec)
+ except Exception:
+ pass
+
+ def _prepare_material_targets(self):
+ """确保每个目标节点都有可编辑材质实例,避免改到共享材质。"""
+ helper = self._property_helpers
+ targets = []
+ seen = set()
+ for node in self._target_nodes:
+ if not self._is_node_valid(node):
+ continue
+ try:
+ materials = []
+ # 优先走编辑器现有材质通路:可覆盖 GeomState 中的材质而不仅是 NodePath 材质。
+ if helper:
+ try:
+ if hasattr(helper, "_ensure_unique_materials_for_node"):
+ helper._ensure_unique_materials_for_node(node)
+ except Exception:
+ pass
+ try:
+ if hasattr(helper, "_get_node_materials"):
+ materials = list(helper._get_node_materials(node) or [])
+ except Exception:
+ materials = []
+
+ if not materials:
+ if node.hasMaterial():
+ mat = node.getMaterial()
+ if mat is not None:
+ materials = [mat]
+
+ if not materials:
+ continue
+
+ for mat in materials:
+ if mat is None:
+ continue
+ material_key = id(mat)
+ pair_key = (id(node), material_key)
+ if pair_key in seen:
+ continue
+ seen.add(pair_key)
+ targets.append((node, mat))
+ except Exception:
+ continue
+ self._material_targets = targets
+
+ def _apply_color(self, color):
+ """同时更新 NodePath 颜色、ColorScale、材质颜色与透明度状态。"""
+ if not self._is_node_valid(self.gameObject):
+ return
+
+ color = Vec4(color)
+ alpha = float(max(0.0, min(1.0, color.getW())))
+ rgba_tuple = (float(color.getX()), float(color.getY()), float(color.getZ()), alpha)
+
+ for node in self._target_nodes:
+ if not self._is_node_valid(node):
+ continue
+ try:
+ node.setColor(*rgba_tuple)
+ except Exception:
+ pass
+ try:
+ node.setColorScale(*rgba_tuple)
+ except Exception:
+ pass
+ try:
+ node.setShaderInput("material_base_color", rgba_tuple)
+ except Exception:
+ pass
+ try:
+ node.setShaderInput("material_opacity", alpha)
+ except Exception:
+ pass
+
+ if alpha < 0.999:
+ try:
+ node.setTransparency(TransparencyAttrib.M_alpha)
+ except Exception:
+ pass
+ try:
+ node.setAlphaScale(alpha)
+ except Exception:
+ pass
+ else:
+ try:
+ node.setTransparency(TransparencyAttrib.M_none)
+ except Exception:
+ pass
+ try:
+ node.setAlphaScale(1.0)
+ except Exception:
+ pass
+
+ for node, material in self._material_targets:
+ if not self._is_node_valid(node):
+ continue
+ try:
+ if self._property_helpers and hasattr(self._property_helpers, "_set_material_base_color"):
+ self._property_helpers._set_material_base_color(material, rgba_tuple)
+ else:
+ self._set_material_base_color(material, rgba_tuple)
+ except Exception:
+ pass
+
+ try:
+ if self._property_helpers and hasattr(self._property_helpers, "_sync_material_node_runtime"):
+ # 刷新 runtime 材质状态,但避免在每帧触发 source->runtime 全量重建。
+ self._property_helpers._sync_material_node_runtime(
+ node,
+ material,
+ refresh_ssbo_runtime=False,
+ )
+ else:
+ node.setMaterial(material, 1)
+ except Exception:
+ pass
+
+ try:
+ if self._property_helpers and hasattr(self._property_helpers, "_invalidate_material_render_cache"):
+ self._property_helpers._invalidate_material_render_cache()
+ except Exception:
+ pass
+
+ def start(self):
+ """脚本开始时调用"""
+ self.log("颜色变化脚本启动!")
+ self.log(f"颜色参数: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}")
+
+ if not self._is_node_valid(self.gameObject):
+ self.log("警告: 挂载对象不可用,颜色变化不会生效")
+ return
+
+ # 记录原始颜色
+ try:
+ self.original_color = self.gameObject.getColor()
+ except Exception:
+ self.original_color = Vec4(1, 1, 1, 1)
+ self.log(f"原始颜色: {self.original_color}")
+
+ self._property_helpers = getattr(self.world, "property_helpers", None)
+ self._target_nodes = self._collect_target_nodes()
+ self._prepare_material_targets()
+ self.log(
+ f"颜色目标节点: {len(self._target_nodes)},材质目标: {len(self._material_targets)}"
+ )
+
+ def update(self, dt):
+ """每帧更新"""
+ if not self.is_changing:
+ return
+ if not self._is_node_valid(self.gameObject):
+ return
+
+ # 累积时间
+ self.time_accumulator += dt
+
+ # 根据模式计算新颜色
+ if self.color_mode == "rainbow":
+ new_color = self._calculate_rainbow_color()
+ elif self.color_mode == "pulse":
+ new_color = self._calculate_pulse_color()
+ elif self.color_mode == "fade":
+ new_color = self._calculate_fade_color()
+ elif self.color_mode == "strobe":
+ new_color = self._calculate_strobe_color()
+ else:
+ new_color = self.base_color
+
+ # 应用颜色
+ self._apply_color(new_color)
+
+ def _calculate_rainbow_color(self):
+ """计算彩虹颜色"""
+ # 使用HSV到RGB的转换创建彩虹效果
+ hue = (self.time_accumulator * self.color_speed) % 1.0
+
+ # 简单的HSV到RGB转换
+ i = int(hue * 6.0)
+ f = (hue * 6.0) - i
+ p = 0.0
+ q = 1.0 - f
+ t = f
+
+ if i % 6 == 0:
+ r, g, b = 1.0, t, p
+ elif i % 6 == 1:
+ r, g, b = q, 1.0, p
+ elif i % 6 == 2:
+ r, g, b = p, 1.0, t
+ elif i % 6 == 3:
+ r, g, b = p, q, 1.0
+ elif i % 6 == 4:
+ r, g, b = t, p, 1.0
+ else:
+ r, g, b = 1.0, p, q
+
+ return Vec4(r * self.intensity, g * self.intensity, b * self.intensity, 1.0)
+
+ def _calculate_pulse_color(self):
+ """计算脉冲颜色"""
+ pulse = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0
+ multiplier = pulse * self.intensity
+ return Vec4(
+ self.base_color.getX() * multiplier,
+ self.base_color.getY() * multiplier,
+ self.base_color.getZ() * multiplier,
+ self.base_color.getW(),
+ )
+
+ def _calculate_fade_color(self):
+ """计算淡入淡出颜色"""
+ fade = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0
+ alpha = fade * self.intensity
+ return Vec4(
+ self.base_color.getX(),
+ self.base_color.getY(),
+ self.base_color.getZ(),
+ alpha,
+ )
+
+ def _calculate_strobe_color(self):
+ """计算闪烁颜色"""
+ # 根据时间间隔切换状态
+ speed = max(float(self.color_speed), 1e-4)
+ interval = 1.0 / (speed * 2.0) # 闪烁间隔
+ if int(self.time_accumulator / interval) % 2 == 0:
+ return Vec4(
+ self.base_color.getX() * self.intensity,
+ self.base_color.getY() * self.intensity,
+ self.base_color.getZ() * self.intensity,
+ self.base_color.getW(),
+ )
+ return Vec4(0.1, 0.1, 0.1, self.base_color.getW()) # 暗色状态
+
+ def set_color_parameters(self, speed=None, mode=None, base_color=None, intensity=None):
+ """设置颜色参数"""
+ if speed is not None:
+ self.color_speed = speed
+ if mode is not None and mode in ["rainbow", "pulse", "fade", "strobe"]:
+ self.color_mode = mode
+ if base_color is not None:
+ self.base_color = Vec4(base_color)
+ if intensity is not None:
+ self.intensity = intensity
+
+ self.log(f"颜色参数更新: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}")
+
+ def toggle_color_change(self):
+ """切换颜色变化状态"""
+ self.is_changing = not self.is_changing
+ status = "恢复" if self.is_changing else "暂停"
+ self.log(f"颜色变化{status}")
+
+ def reset_color(self):
+ """重置到原始颜色"""
+ if self.original_color is None:
+ return
+ self._apply_color(self.original_color)
+ self.time_accumulator = 0.0
+ self.log("颜色已重置到原始值")
+
+ def set_solid_color(self, r=1.0, g=1.0, b=1.0, a=1.0):
+ """设置固定颜色"""
+ color = Vec4(r, g, b, a)
+ self.base_color = color
+ self._apply_color(color)
+ self.log(f"设置固定颜色: {color}")
+
+ def on_destroy(self):
+ """脚本销毁时调用"""
+ self.log("颜色变化脚本停止")
diff --git a/templates/webgl/frontend_demo.html b/templates/webgl/frontend_demo.html
index 78091244..725cc2cb 100644
--- a/templates/webgl/frontend_demo.html
+++ b/templates/webgl/frontend_demo.html
@@ -259,6 +259,26 @@
+
+
+
+
+
+
+
+
+
+
这是 Web 端支持脚本示例。浏览器不会直接执行 Python 文件,而是按脚本名挂载对应的 Web 适配逻辑。
+
+
@@ -280,6 +300,7 @@
const logBox = document.getElementById("log-box");
const nodeSelect = document.getElementById("node-select");
const clipInput = document.getElementById("clip-input");
+ const scriptSelect = document.getElementById("script-select");
const pendingReplies = new Map();
let sequence = 0;
@@ -389,6 +410,26 @@
return nodes;
}
+ async function refreshSupportedScripts() {
+ const supported = await sendCommand("scripts.listSupported");
+ if (!Array.isArray(supported) || supported.length === 0) {
+ return supported;
+ }
+
+ const currentValue = scriptSelect.value;
+ scriptSelect.innerHTML = "";
+ for (const script of supported) {
+ const option = document.createElement("option");
+ option.value = script.name || script.key || "";
+ option.textContent = (script.name || script.key || "script") + (script.summary ? " - " + script.summary : "");
+ scriptSelect.appendChild(option);
+ }
+ if (currentValue) {
+ scriptSelect.value = currentValue;
+ }
+ return supported;
+ }
+
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.source !== "eg-webgl") {
@@ -437,6 +478,7 @@
document.getElementById("handshake-btn").addEventListener("click", () => {
runAction("handshake", async () => {
const result = await sendHandshake();
+ await refreshSupportedScripts();
if (result.ready && !readyReceived) {
await refreshNodes();
}
@@ -571,6 +613,27 @@
});
});
+ document.getElementById("attach-script-btn").addEventListener("click", () => {
+ runAction("scripts.attach", async () => {
+ const scriptName = scriptSelect.value;
+ return sendCommand("scripts.attach", {
+ selector: getSelectedSelector(),
+ script: {
+ name: scriptName,
+ },
+ });
+ });
+ });
+
+ document.getElementById("detach-script-btn").addEventListener("click", () => {
+ runAction("scripts.detach", async () => {
+ return sendCommand("scripts.detach", {
+ selector: getSelectedSelector(),
+ name: scriptSelect.value,
+ });
+ });
+ });
+
viewerFrame.addEventListener("load", () => {
setStatus("Viewer iframe loaded. Click Handshake to start.");
});
diff --git a/templates/webgl/index.html b/templates/webgl/index.html
index 361525c9..d8af10c4 100644
--- a/templates/webgl/index.html
+++ b/templates/webgl/index.html
@@ -35,6 +35,18 @@ viewer.nodes.setTransform(
{ coordSystem: "panda", space: "local" },
);
+const supportedScripts = viewer.scripts.listSupported();
+console.log("web supported scripts", supportedScripts);
+
+viewer.scripts.attach({ publicId: hero.publicId }, {
+ name: "MoverScript",
+ params: {
+ move_axis: "x",
+ move_distance: 3,
+ move_speed: 1.5,
+ },
+});
+
viewer.events.on("animationFinished", (payload) => {
console.log("animation finished", payload);
});
@@ -56,14 +68,18 @@ iframe.contentWindow.postMessage({
source: "eg-frontend",
type: "command",
id: "cmd-1",
- command: "animation.play",
+ command: "scripts.attach",
payload: {
selector: { publicId: "model:asset-guid:root" },
- options: { clip: "Idle", loop: true },
+ script: {
+ name: "RotatorScript",
+ params: { rotation_speed_y: 45 },
+ },
},
}, iframe.src ? new URL(iframe.src).origin : "*");
Message protocol: commands use { source: "eg-frontend", id, type: "command", command, payload }. Responses/events use { source: "eg-webgl", type, replyTo?, event?, ok?, result?, error? }.
Events: ready, error, animationFinished, stateChanged.
+
Script note: exported WebGL can read existing script metadata, but browser-side runtime only executes Web-supported script adapters, not arbitrary Python files.
Demo page: after export, open frontend_demo.html to see a working iframe host example.
diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js
index df568e0c..0b9aad25 100644
--- a/templates/webgl/viewer.js
+++ b/templates/webgl/viewer.js
@@ -1597,6 +1597,142 @@ function normalizeScriptName(name) {
return canonicalizeNameSegment(text);
}
+function buildSupportedScriptCatalog() {
+ const descriptors = [
+ {
+ key: "moverscript",
+ name: "MoverScript",
+ aliases: ["mover"],
+ summary: "Move the node back and forth along one Panda axis.",
+ defaultParams: {
+ move_axis: "x",
+ move_distance: 5.0,
+ move_speed: 2.0,
+ current_direction: 1,
+ current_distance: 0.0,
+ is_moving: true,
+ },
+ },
+ {
+ key: "rotatorscript",
+ name: "RotatorScript",
+ aliases: ["rotator"],
+ summary: "Rotate the node continuously around the Y axis in Web view.",
+ defaultParams: {
+ rotation_speed_y: 30.0,
+ is_rotating: true,
+ },
+ },
+ {
+ key: "scalerscript",
+ name: "ScalerScript",
+ aliases: ["scaler"],
+ summary: "Animate the node scale with a sine wave.",
+ defaultParams: {
+ base_scale: 1.0,
+ scale_amplitude: 0.3,
+ scale_speed: 2.0,
+ uniform_scale: true,
+ time_accumulator: 0.0,
+ is_scaling: true,
+ },
+ },
+ {
+ key: "colorchangerscript",
+ name: "ColorChangerScript",
+ aliases: ["colorchanger"],
+ summary: "Animate mesh material color or opacity over time.",
+ defaultParams: {
+ color_speed: 1.0,
+ color_mode: "rainbow",
+ base_color: [1, 1, 1, 1],
+ intensity: 1.0,
+ time_accumulator: 0.0,
+ is_changing: true,
+ },
+ },
+ {
+ key: "bouncerscript",
+ name: "BouncerScript",
+ aliases: ["bouncer"],
+ summary: "Bounce the node vertically using a simple waveform.",
+ defaultParams: {
+ jump_height: 2.0,
+ jump_speed: 3.0,
+ bounce_type: "sine",
+ time_accumulator: 0.0,
+ is_bouncing: true,
+ bounce_direction: 1,
+ },
+ },
+ {
+ key: "followerscript",
+ name: "FollowerScript",
+ aliases: ["follower"],
+ summary: "Follow another exported node selected by id/publicId/name reference.",
+ defaultParams: {
+ follow_speed: 5.0,
+ follow_distance: 2.0,
+ is_following: true,
+ target_ref: null,
+ },
+ },
+ {
+ key: "comboanimatorscript",
+ name: "ComboAnimatorScript",
+ aliases: ["comboanimator"],
+ summary: "Rotate and bounce the node using a built-in Web combo animation.",
+ defaultParams: {
+ time: 0.0,
+ is_active: true,
+ },
+ },
+ ];
+
+ const catalog = new Map();
+ for (const descriptor of descriptors) {
+ const aliases = Array.from(new Set([
+ descriptor.key,
+ descriptor.name,
+ ...(Array.isArray(descriptor.aliases) ? descriptor.aliases : []),
+ ].map((value) => normalizeScriptName(value)).filter((value) => !!value)));
+ const normalized = {
+ key: normalizeScriptName(descriptor.key || descriptor.name || ""),
+ name: String(descriptor.name || descriptor.key || ""),
+ aliases,
+ summary: String(descriptor.summary || ""),
+ defaultParams: cloneJsonSafe(descriptor.defaultParams, {}),
+ };
+ for (const alias of aliases) {
+ catalog.set(alias, normalized);
+ }
+ }
+ return catalog;
+}
+
+const WEB_SUPPORTED_SCRIPT_CATALOG = buildSupportedScriptCatalog();
+
+function getWebSupportedScriptDescriptor(scriptName) {
+ return WEB_SUPPORTED_SCRIPT_CATALOG.get(normalizeScriptName(scriptName)) || null;
+}
+
+function listWebSupportedScripts() {
+ const out = [];
+ const seen = new Set();
+ for (const descriptor of WEB_SUPPORTED_SCRIPT_CATALOG.values()) {
+ if (!descriptor?.key || seen.has(descriptor.key)) continue;
+ seen.add(descriptor.key);
+ out.push({
+ key: descriptor.key,
+ name: descriptor.name,
+ aliases: Array.isArray(descriptor.aliases) ? descriptor.aliases.slice() : [],
+ summary: descriptor.summary || "",
+ defaultParams: cloneJsonSafe(descriptor.defaultParams, {}),
+ });
+ }
+ return out;
+}
+
function markObjectTransformDirty(obj) {
if (!obj || !obj.isObject3D) return;
obj.updateMatrix();
@@ -2006,6 +2142,7 @@ function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, own
const withOwnerState = (state) => ({
ownerId: entry?.id || "",
ownerPublicId: entry?.publicId || "",
+ params: cloneJsonSafe(params, {}),
...state,
});
@@ -2235,10 +2372,9 @@ function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, own
return null;
}
-function buildScriptRuntimeStates(THREE, nodes, nodeMap) {
+function buildScriptRuntimeStates(THREE, nodes, nodeMap, nodeNameLookup = buildNodeNameLookup(nodeMap)) {
const runtimes = [];
let unsupportedCount = 0;
- const nodeNameLookup = buildNodeNameLookup(nodeMap);
for (const node of nodes) {
const entry = node;
@@ -2270,6 +2406,217 @@ function buildScriptRuntimeStates(THREE, nodes, nodeMap) {
return { runtimes, unsupportedCount };
}
+function ensureDeclaredScripts(entry) {
+ if (!entry?.nodeData || typeof entry.nodeData !== "object") {
+ throw createViewerError("invalid_node_data", "Node data is unavailable for script operations", {
+ node: buildNodeSelectorPayload(entry),
+ });
+ }
+ if (!Array.isArray(entry.nodeData.scripts)) {
+ entry.nodeData.scripts = [];
+ }
+ return entry.nodeData.scripts;
+}
+
+function findDeclaredScriptIndex(entry, scriptNameOrKey) {
+ const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : [];
+ const wantedKey = normalizeScriptName(scriptNameOrKey);
+ if (!wantedKey) return -1;
+ for (let i = 0; i < declared.length; i += 1) {
+ const key = normalizeScriptName(declared[i]?.name || declared[i]?.key || "");
+ if (key === wantedKey) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+function normalizeScriptConfigInput(scriptInput, fallbackName = "") {
+ if (typeof scriptInput === "string") {
+ const trimmed = String(scriptInput).trim();
+ if (!trimmed) {
+ throw createViewerError("invalid_script_config", "Script name must not be empty");
+ }
+ return { name: trimmed, enabled: true, params: {} };
+ }
+
+ if (!scriptInput || typeof scriptInput !== "object") {
+ throw createViewerError("invalid_script_config", "Script config must be a string or object");
+ }
+
+ const name = String(scriptInput.name || fallbackName || "").trim();
+ if (!name) {
+ throw createViewerError("invalid_script_config", "Script config must include a name");
+ }
+
+ const params = (scriptInput.params && typeof scriptInput.params === "object")
+ ? cloneJsonSafe(scriptInput.params, {})
+ : {};
+
+ const out = {
+ name,
+ params,
+ };
+
+ if (Object.prototype.hasOwnProperty.call(scriptInput, "enabled")) {
+ out.enabled = toBoolean(scriptInput.enabled, true);
+ }
+
+ for (const key of ["file", "project_relative_path", "script_guid", "relative_path", "path"]) {
+ const value = String(scriptInput[key] || "").trim();
+ if (value) {
+ out[key] = value;
+ }
+ }
+
+ return out;
+}
+
+function mergeScriptConfig(existingConfig, nextConfig) {
+ const existing = (existingConfig && typeof existingConfig === "object") ? existingConfig : {};
+ const next = normalizeScriptConfigInput(nextConfig, existing?.name || "");
+ return {
+ ...cloneJsonSafe(existing, {}),
+ ...next,
+ name: next.name || String(existing?.name || ""),
+ enabled: Object.prototype.hasOwnProperty.call(next, "enabled")
+ ? !!next.enabled
+ : toBoolean(existing?.enabled, true),
+ params: {
+ ...(existing?.params && typeof existing.params === "object" ? cloneJsonSafe(existing.params, {}) : {}),
+ ...(next?.params && typeof next.params === "object" ? cloneJsonSafe(next.params, {}) : {}),
+ },
+ };
+}
+
+function refreshUnsupportedScriptStats(runtime) {
+ let unsupportedCount = 0;
+ for (const entry of runtime?.nodeOrder || []) {
+ const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : [];
+ entry.unsupportedScripts = [];
+ for (const scriptCfg of declared) {
+ if (getWebSupportedScriptDescriptor(scriptCfg?.name || scriptCfg?.key || "")) {
+ continue;
+ }
+ unsupportedCount += 1;
+ entry.unsupportedScripts.push(String(scriptCfg?.name || ""));
+ }
+ }
+ runtime.unsupportedScriptCount = unsupportedCount;
+ return unsupportedCount;
+}
+
+function removeScriptRuntime(runtime, entry, scriptNameOrKey) {
+ const wantedKey = normalizeScriptName(scriptNameOrKey);
+ if (!wantedKey) return 0;
+ let removed = 0;
+
+ if (Array.isArray(entry?.scriptStates)) {
+ for (let i = entry.scriptStates.length - 1; i >= 0; i -= 1) {
+ const state = entry.scriptStates[i];
+ const stateKey = normalizeScriptName(state?.name || state?.key || "");
+ if (stateKey === wantedKey) {
+ entry.scriptStates.splice(i, 1);
+ removed += 1;
+ }
+ }
+ }
+
+ if (Array.isArray(runtime?.scriptRuntimes)) {
+ for (let i = runtime.scriptRuntimes.length - 1; i >= 0; i -= 1) {
+ const state = runtime.scriptRuntimes[i];
+ const stateKey = normalizeScriptName(state?.name || state?.key || "");
+ const sameOwner = String(state?.ownerId || "") === String(entry?.id || "");
+ if (sameOwner && stateKey === wantedKey) {
+ runtime.scriptRuntimes.splice(i, 1);
+ }
+ }
+ }
+
+ return removed;
+}
+
+function instantiateScriptRuntime(THREE, runtime, entry, scriptCfg) {
+ const state = createScriptState(
+ THREE,
+ scriptCfg,
+ entry,
+ runtime?.nodeMap || new Map(),
+ runtime?.nodeNameLookup || buildNodeNameLookup(runtime?.nodeMap || new Map()),
+ entry?.name || entry?.id || "",
+ );
+ if (!state) {
+ throw createViewerError("script_not_supported", "Script is not supported in the Web viewer", {
+ node: buildNodeSelectorPayload(entry),
+ script: scriptCfg?.name || "",
+ supportedScripts: listWebSupportedScripts(),
+ });
+ }
+ entry.scriptStates.push(state);
+ if (Array.isArray(runtime?.scriptRuntimes)) {
+ runtime.scriptRuntimes.push(state);
+ }
+ refreshUnsupportedScriptStats(runtime);
+ return state;
+}
+
+function attachScriptToEntry(THREE, runtime, entry, scriptInput) {
+ const nextConfig = normalizeScriptConfigInput(scriptInput);
+ const descriptor = getWebSupportedScriptDescriptor(nextConfig.name);
+ if (!descriptor) {
+ throw createViewerError("script_not_supported", "Requested script cannot run in the Web viewer", {
+ node: buildNodeSelectorPayload(entry),
+ script: nextConfig.name,
+ supportedScripts: listWebSupportedScripts(),
+ });
+ }
+
+ const declared = ensureDeclaredScripts(entry);
+ const existingIndex = findDeclaredScriptIndex(entry, nextConfig.name);
+ const mergedConfig = existingIndex >= 0
+ ? mergeScriptConfig(declared[existingIndex], { ...nextConfig, name: descriptor.name })
+ : mergeScriptConfig({ name: descriptor.name, enabled: true, params: descriptor.defaultParams }, nextConfig);
+
+ if (existingIndex >= 0) {
+ declared.splice(existingIndex, 1, mergedConfig);
+ } else {
+ declared.push(mergedConfig);
+ }
+
+ removeScriptRuntime(runtime, entry, descriptor.key);
+ instantiateScriptRuntime(THREE, runtime, entry, mergedConfig);
+ return mergedConfig;
+}
+
+function detachScriptFromEntry(runtime, entry, scriptNameOrKey) {
+ const wantedKey = normalizeScriptName(scriptNameOrKey);
+ if (!wantedKey) {
+ throw createViewerError("invalid_script_config", "Script name must not be empty");
+ }
+
+ const declared = ensureDeclaredScripts(entry);
+ let removedDeclared = 0;
+ for (let i = declared.length - 1; i >= 0; i -= 1) {
+ const key = normalizeScriptName(declared[i]?.name || declared[i]?.key || "");
+ if (key === wantedKey) {
+ declared.splice(i, 1);
+ removedDeclared += 1;
+ }
+ }
+
+ const removedRuntime = removeScriptRuntime(runtime, entry, wantedKey);
+ refreshUnsupportedScriptStats(runtime);
+
+ if (removedDeclared === 0 && removedRuntime === 0) {
+ throw createViewerError("script_not_found", "Requested script was not found on the node", {
+ node: buildNodeSelectorPayload(entry),
+ script: scriptNameOrKey,
+ });
+ }
+
+ return removedDeclared + removedRuntime;
+}
+
function makeRuntimeEntry(node, obj) {
return {
id: String(node?.id || ""),
@@ -2344,11 +2691,17 @@ function buildScriptStates(entry) {
const name = String(scriptCfg?.name || "");
const key = normalizeScriptName(name);
const runtimeState = runtimeByKey.get(key);
+ const descriptor = getWebSupportedScriptDescriptor(name || key);
out.push({
name,
key,
enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true),
supported: !!runtimeState,
+ webSupported: !!descriptor,
+ params: cloneJsonSafe(scriptCfg?.params ?? runtimeState?.params ?? {}, {}),
+ file: String(scriptCfg?.file || ""),
+ projectRelativePath: String(scriptCfg?.project_relative_path || ""),
+ scriptGuid: String(scriptCfg?.script_guid || ""),
});
if (runtimeState) {
runtimeByKey.delete(key);
@@ -2356,11 +2709,19 @@ function buildScriptStates(entry) {
}
for (const runtimeState of runtimeByKey.values()) {
+ const runtimeName = String(runtimeState?.name || "");
+ const runtimeKey = normalizeScriptName(runtimeName || runtimeState?.key || "");
+ const descriptor = getWebSupportedScriptDescriptor(runtimeName || runtimeKey);
out.push({
- name: String(runtimeState?.name || ""),
- key: normalizeScriptName(runtimeState?.name || runtimeState?.key || ""),
+ name: runtimeName,
+ key: runtimeKey,
enabled: !!runtimeState.enabled,
supported: true,
+ webSupported: !!descriptor,
+ params: cloneJsonSafe(runtimeState?.params ?? {}, {}),
+ file: "",
+ projectRelativePath: "",
+ scriptGuid: "",
});
}
@@ -2410,6 +2771,7 @@ function buildSceneInfo(runtime) {
environment: cloneJsonSafe(runtime?.manifest?.environment, {}),
nodeCount: runtime?.nodeOrder?.length || 0,
unsupportedScriptCount: runtime?.unsupportedScriptCount || 0,
+ webSupportedScripts: listWebSupportedScripts(),
nodes: (runtime?.nodeOrder || []).map((entry) => buildNodeSummary(entry)),
};
}
@@ -2692,6 +3054,39 @@ function createViewerApi(runtime, THREE) {
});
},
},
+ scripts: {
+ listSupported() {
+ return runApiAction(runtime, "scripts.listSupported", () => {
+ return listWebSupportedScripts();
+ });
+ },
+ attach(selector, script) {
+ return runApiAction(runtime, "scripts.attach", () => {
+ const entry = resolveRuntimeEntry(runtime, selector);
+ attachScriptToEntry(THREE, runtime, entry, script);
+ const result = buildNodeState(THREE, entry);
+ emitRuntimeEvent(runtime, "stateChanged", {
+ node: buildNodeSelectorPayload(entry),
+ changeType: "script",
+ state: result,
+ });
+ return result;
+ });
+ },
+ detach(selector, scriptNameOrKey) {
+ return runApiAction(runtime, "scripts.detach", () => {
+ const entry = resolveRuntimeEntry(runtime, selector);
+ detachScriptFromEntry(runtime, entry, scriptNameOrKey);
+ const result = buildNodeState(THREE, entry);
+ emitRuntimeEvent(runtime, "stateChanged", {
+ node: buildNodeSelectorPayload(entry),
+ changeType: "script",
+ state: result,
+ });
+ return result;
+ });
+ },
+ },
events: {
on(eventName, handler) {
return runtime.eventHub.on(eventName, handler);
@@ -2720,6 +3115,9 @@ function createMessageBridge(runtime, api) {
["animation.stop", (payload) => api.animation.stop(payload?.selector, payload?.options ?? {})],
["animation.seek", (payload) => api.animation.seek(payload?.selector, payload?.time)],
["animation.setSpeed", (payload) => api.animation.setSpeed(payload?.selector, payload?.speed)],
+ ["scripts.listSupported", () => api.scripts.listSupported()],
+ ["scripts.attach", (payload) => api.scripts.attach(payload?.selector, payload?.script ?? payload)],
+ ["scripts.detach", (payload) => api.scripts.detach(payload?.selector, payload?.name ?? payload?.key ?? payload?.script ?? "")],
]);
let trustedOrigin = "";
@@ -2974,9 +3372,12 @@ async function bootstrap() {
nodesById: new Map(),
nodesByPublicId: new Map(),
nodesByName: new Map(),
+ nodeMap: new Map(),
+ nodeNameLookup: new Map(),
eventHub: createEventHub(),
readyDeferred: makeDeferred(),
animationControllers: [],
+ scriptRuntimes: [],
unsupportedScriptCount: 0,
isReady: false,
bridge: null,
@@ -3088,6 +3489,7 @@ async function bootstrap() {
nodeMap.set(String(node.id || ""), obj);
const entry = makeRuntimeEntry(node, obj);
registerRuntimeEntry(runtime, entry);
+ runtime.nodeMap.set(String(node.id || ""), obj);
if (node.kind === "model") {
const modelUri = node.model?.uri;
@@ -3166,8 +3568,10 @@ async function bootstrap() {
const composer = bloomSetup.composer;
runtime.composer = composer;
- const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, nodeMap);
+ runtime.nodeNameLookup = buildNodeNameLookup(runtime.nodeMap);
+ const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, runtime.nodeMap, runtime.nodeNameLookup);
const scriptStates = scriptRuntime.runtimes;
+ runtime.scriptRuntimes = scriptStates;
runtime.unsupportedScriptCount = scriptRuntime.unsupportedCount;
const resize = () => {
@@ -3207,7 +3611,7 @@ async function bootstrap() {
}
}
scene.updateMatrixWorld(true);
- for (const state of scriptStates) {
+ for (const state of runtime.scriptRuntimes) {
try {
state.update(dt);
} catch (err) {
diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py
index aab4a09f..d8901259 100644
--- a/ui/panels/app_actions.py
+++ b/ui/panels/app_actions.py
@@ -205,7 +205,7 @@ class AppActions:
def _mount_script_to_selected(self, script_name):
"""挂载脚本到选中对象"""
- selected_node = self._get_selection_node()
+ selected_node = self._get_script_target_node() if hasattr(self, "_get_script_target_node") else self._get_selection_node()
if not selected_node or selected_node.isEmpty():
self.add_error_message("请先选择一个对象")
@@ -213,9 +213,22 @@ class AppActions:
try:
if hasattr(self, 'script_manager') and self.script_manager:
+ # 挂载前尝试重载该脚本,确保使用磁盘上的最新实现。
+ try:
+ self.script_manager.reload_script(script_name)
+ except Exception:
+ pass
script_component = self.script_manager.add_script_to_object(selected_node, script_name)
if script_component:
+ script_info = {}
+ try:
+ script_info = self.script_manager.get_script_info(script_name) or {}
+ except Exception:
+ script_info = {}
+ script_file = str(script_info.get("file", "") or "").strip()
self.add_success_message(f"脚本 {script_name} 已挂载到 {selected_node.getName()}")
+ if script_file:
+ self.add_info_message(f"脚本文件: {script_file}")
print(f"[脚本系统] 挂载脚本: {script_name} -> {selected_node.getName()}")
else:
self.add_error_message(f"挂载脚本 {script_name} 失败")
@@ -227,7 +240,7 @@ class AppActions:
def _unmount_script_from_selected(self, script_name):
"""从选中对象卸载脚本"""
- selected_node = self._get_selection_node()
+ selected_node = self._get_script_target_node() if hasattr(self, "_get_script_target_node") else self._get_selection_node()
if not selected_node or selected_node.isEmpty():
self.add_error_message("请先选择一个对象")
diff --git a/ui/panels/panel_delegates.py b/ui/panels/panel_delegates.py
index 8e6d7461..a6b4903e 100644
--- a/ui/panels/panel_delegates.py
+++ b/ui/panels/panel_delegates.py
@@ -72,6 +72,49 @@ class PanelDelegates:
return runtime_node
return self._get_selection_source_node()
+ def _get_script_target_node(self):
+ """
+ Return the runtime-visible node that scripts should mount to.
+
+ In SSBO mode, mounting to source tree nodes (e.g. ssbo_source_scene_root)
+ won't affect the visible runtime scene. Prefer dynamic runtime object/proxy/model.
+ """
+ ssbo_editor = getattr(self, "ssbo_editor", None)
+ if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection():
+ controller = getattr(ssbo_editor, "controller", None)
+ selected_ids = list(getattr(ssbo_editor, "selected_ids", []) or [])
+
+ # Single selection: prefer the exact dynamic runtime object.
+ if controller and len(selected_ids) == 1:
+ try:
+ obj_np = (getattr(controller, "id_to_object_np", {}) or {}).get(selected_ids[0])
+ except Exception:
+ obj_np = None
+ if obj_np and self._node_is_valid(obj_np, require_attached=False):
+ return obj_np
+
+ # Multi-selection proxy (if exists) should drive runtime edits.
+ group_proxy = getattr(ssbo_editor, "_group_proxy", None)
+ if group_proxy and self._node_is_valid(group_proxy, require_attached=False):
+ return group_proxy
+
+ # Fallback scene node, but skip source-tree nodes.
+ scene_node = getattr(ssbo_editor, "get_selection_scene_node", lambda: None)()
+ if scene_node and self._node_is_valid(scene_node, require_attached=False):
+ try:
+ is_source = bool(getattr(ssbo_editor, "is_source_tree_node", lambda *_: False)(scene_node))
+ except Exception:
+ is_source = False
+ if not is_source:
+ return scene_node
+
+ # Last fallback: runtime root model (still visible), not source root.
+ runtime_model = getattr(ssbo_editor, "model", None)
+ if runtime_model and self._node_is_valid(runtime_model, require_attached=False):
+ return runtime_model
+
+ return self._get_selection_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():
diff --git a/ui/panels/script_panels.py b/ui/panels/script_panels.py
index 755513e7..2f0da99b 100644
--- a/ui/panels/script_panels.py
+++ b/ui/panels/script_panels.py
@@ -260,8 +260,10 @@ class ScriptPanels:
def _draw_script_mounting_group(self):
"""绘制脚本挂载组"""
if imgui.collapsing_header("脚本挂载"):
- # 显示当前选中对象
- selected_node = self._get_selection_source_node()
+ # 与挂载/卸载操作保持一致:统一使用脚本实际挂载目标节点。
+ selected_node = self._get_script_target_node() if hasattr(self, "_get_script_target_node") else self._get_selection_node()
+ if (not selected_node) or selected_node.isEmpty():
+ selected_node = self._get_selection_source_node()
if selected_node and not selected_node.isEmpty():
imgui.text("选中对象:")