脚本修复

This commit is contained in:
Rowland 2026-04-21 15:57:20 +08:00
parent 22ccfcd55d
commit b4fd0c2e68
10 changed files with 933 additions and 183 deletions

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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("颜色变化脚本停止")
#!/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("颜色变化脚本停止")

View File

@ -259,6 +259,26 @@
</div>
</div>
<div class="section">
<div>
<label for="script-select">Web Script</label>
<select id="script-select">
<option value="MoverScript">MoverScript</option>
<option value="RotatorScript">RotatorScript</option>
<option value="ScalerScript">ScalerScript</option>
<option value="ColorChangerScript">ColorChangerScript</option>
<option value="BouncerScript">BouncerScript</option>
<option value="FollowerScript">FollowerScript</option>
<option value="ComboAnimatorScript">ComboAnimatorScript</option>
</select>
</div>
<div class="row two">
<button id="attach-script-btn">Attach Script</button>
<button id="detach-script-btn" class="secondary">Detach Script</button>
</div>
<div class="hint">这是 Web 端支持脚本示例。浏览器不会直接执行 Python 文件,而是按脚本名挂载对应的 Web 适配逻辑。</div>
</div>
<div class="section">
<label for="log-box">Message Log</label>
<textarea id="log-box" readonly></textarea>
@ -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.");
});

View File

@ -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);
});</code></pre>
@ -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 : "*");</code></pre>
<p><strong>Message protocol:</strong> commands use <code>{ source: "eg-frontend", id, type: "command", command, payload }</code>. Responses/events use <code>{ source: "eg-webgl", type, replyTo?, event?, ok?, result?, error? }</code>.</p>
<p><strong>Events:</strong> <code>ready</code>, <code>error</code>, <code>animationFinished</code>, <code>stateChanged</code>.</p>
<p><strong>Script note:</strong> exported WebGL can read existing script metadata, but browser-side runtime only executes Web-supported script adapters, not arbitrary Python files.</p>
<p><strong>Demo page:</strong> after export, open <code>frontend_demo.html</code> to see a working iframe host example.</p>
</div>
</details>

View File

@ -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) {

View File

@ -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("请先选择一个对象")

View File

@ -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():

View File

@ -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("选中对象:")