脚本修复
This commit is contained in:
parent
22ccfcd55d
commit
b4fd0c2e68
15
imgui.ini
15
imgui.ini
@ -37,9 +37,9 @@ DockId=0x00000007,0
|
|||||||
|
|
||||||
[Window][属性面板]
|
[Window][属性面板]
|
||||||
Pos=1504,20
|
Pos=1504,20
|
||||||
Size=346,996
|
Size=346,498
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000002,0
|
DockId=0x00000003,0
|
||||||
|
|
||||||
[Window][控制台]
|
[Window][控制台]
|
||||||
Pos=341,617
|
Pos=341,617
|
||||||
@ -48,9 +48,10 @@ Collapsed=0
|
|||||||
DockId=0x00000006,1
|
DockId=0x00000006,1
|
||||||
|
|
||||||
[Window][脚本管理]
|
[Window][脚本管理]
|
||||||
Pos=1950,20
|
Pos=1504,520
|
||||||
Size=610,995
|
Size=346,496
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
DockId=0x00000004,0
|
||||||
|
|
||||||
[Window][中文显示测试]
|
[Window][中文显示测试]
|
||||||
Pos=60,60
|
Pos=60,60
|
||||||
@ -152,7 +153,7 @@ Collapsed=0
|
|||||||
Pos=2214,20
|
Pos=2214,20
|
||||||
Size=346,1331
|
Size=346,1331
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000002,1
|
DockId=0x00000003,1
|
||||||
|
|
||||||
[Window][LUI测试控制面板]
|
[Window][LUI测试控制面板]
|
||||||
Pos=6,10
|
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=0x0000000D Parent=0x00000005 SizeRef=1318,383 HiddenTabBar=1 Selected=0x43A39006
|
||||||
DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051
|
DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051
|
||||||
DockNode ID=0x00000006 Parent=0x00000008 SizeRef=2048,399 Selected=0x3A2E05C3
|
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
|
||||||
|
|
||||||
|
|||||||
2
main.py
2
main.py
@ -333,7 +333,7 @@ class MyWorld(PanelDelegates, CoreWorld):
|
|||||||
"scene_tree": True,
|
"scene_tree": True,
|
||||||
"property": True,
|
"property": True,
|
||||||
"console": True,
|
"console": True,
|
||||||
"script": not self.use_ssbo_mouse_picking,
|
"script": True,
|
||||||
"toolbar": True,
|
"toolbar": True,
|
||||||
"resources": True,
|
"resources": True,
|
||||||
"web": False,
|
"web": False,
|
||||||
|
|||||||
@ -1374,6 +1374,7 @@ class WebGLPackager:
|
|||||||
out: List[Dict[str, Any]] = []
|
out: List[Dict[str, Any]] = []
|
||||||
loader = getattr(script_manager, "loader", None)
|
loader = getattr(script_manager, "loader", None)
|
||||||
find_script_file = getattr(loader, "find_script_file", None) if loader else 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:
|
for component in script_components:
|
||||||
script_instance = getattr(component, "script_instance", None)
|
script_instance = getattr(component, "script_instance", None)
|
||||||
@ -1395,7 +1396,17 @@ class WebGLPackager:
|
|||||||
"name": script_name,
|
"name": script_name,
|
||||||
"enabled": bool(getattr(component, "enabled", True)),
|
"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
|
entry["file"] = script_file
|
||||||
|
|
||||||
params = self._serialize_script_instance_params(script_instance)
|
params = self._serialize_script_instance_params(script_instance)
|
||||||
@ -1436,9 +1447,10 @@ class WebGLPackager:
|
|||||||
"name": script_name,
|
"name": script_name,
|
||||||
"enabled": bool(item.get("enabled", True)),
|
"enabled": bool(item.get("enabled", True)),
|
||||||
}
|
}
|
||||||
script_file = str(item.get("file", "")).strip()
|
for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"):
|
||||||
if script_file:
|
value = str(item.get(key, "") or "").strip()
|
||||||
entry["file"] = script_file
|
if value:
|
||||||
|
entry[key] = value
|
||||||
params = item.get("params")
|
params = item.get("params")
|
||||||
if isinstance(params, dict) and params:
|
if isinstance(params, dict) and params:
|
||||||
entry["params"] = params
|
entry["params"] = params
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from core.script_system import ScriptBase
|
from core.script_system import ScriptBase
|
||||||
from panda3d.core import Vec4
|
from panda3d.core import TransparencyAttrib, Vec4
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
|
||||||
class ColorChangerScript(ScriptBase):
|
class ColorChangerScript(ScriptBase):
|
||||||
"""颜色变化脚本类"""
|
"""颜色变化脚本类"""
|
||||||
|
|
||||||
@ -27,19 +28,211 @@ class ColorChangerScript(ScriptBase):
|
|||||||
self.is_changing = True # 是否正在变化
|
self.is_changing = True # 是否正在变化
|
||||||
self.strobe_state = False # 闪烁状态
|
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):
|
def start(self):
|
||||||
"""脚本开始时调用"""
|
"""脚本开始时调用"""
|
||||||
self.log("颜色变化脚本启动!")
|
self.log("颜色变化脚本启动!")
|
||||||
self.log(f"颜色参数: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}")
|
self.log(f"颜色参数: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}")
|
||||||
|
|
||||||
|
if not self._is_node_valid(self.gameObject):
|
||||||
|
self.log("警告: 挂载对象不可用,颜色变化不会生效")
|
||||||
|
return
|
||||||
|
|
||||||
# 记录原始颜色
|
# 记录原始颜色
|
||||||
self.original_color = self.gameObject.getColor()
|
try:
|
||||||
|
self.original_color = self.gameObject.getColor()
|
||||||
|
except Exception:
|
||||||
|
self.original_color = Vec4(1, 1, 1, 1)
|
||||||
self.log(f"原始颜色: {self.original_color}")
|
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):
|
def update(self, dt):
|
||||||
"""每帧更新"""
|
"""每帧更新"""
|
||||||
if not self.is_changing:
|
if not self.is_changing:
|
||||||
return
|
return
|
||||||
|
if not self._is_node_valid(self.gameObject):
|
||||||
|
return
|
||||||
|
|
||||||
# 累积时间
|
# 累积时间
|
||||||
self.time_accumulator += dt
|
self.time_accumulator += dt
|
||||||
@ -57,7 +250,7 @@ class ColorChangerScript(ScriptBase):
|
|||||||
new_color = self.base_color
|
new_color = self.base_color
|
||||||
|
|
||||||
# 应用颜色
|
# 应用颜色
|
||||||
self.gameObject.setColor(new_color)
|
self._apply_color(new_color)
|
||||||
|
|
||||||
def _calculate_rainbow_color(self):
|
def _calculate_rainbow_color(self):
|
||||||
"""计算彩虹颜色"""
|
"""计算彩虹颜色"""
|
||||||
@ -94,7 +287,7 @@ class ColorChangerScript(ScriptBase):
|
|||||||
self.base_color.getX() * multiplier,
|
self.base_color.getX() * multiplier,
|
||||||
self.base_color.getY() * multiplier,
|
self.base_color.getY() * multiplier,
|
||||||
self.base_color.getZ() * multiplier,
|
self.base_color.getZ() * multiplier,
|
||||||
self.base_color.getW()
|
self.base_color.getW(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_fade_color(self):
|
def _calculate_fade_color(self):
|
||||||
@ -105,22 +298,22 @@ class ColorChangerScript(ScriptBase):
|
|||||||
self.base_color.getX(),
|
self.base_color.getX(),
|
||||||
self.base_color.getY(),
|
self.base_color.getY(),
|
||||||
self.base_color.getZ(),
|
self.base_color.getZ(),
|
||||||
alpha
|
alpha,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_strobe_color(self):
|
def _calculate_strobe_color(self):
|
||||||
"""计算闪烁颜色"""
|
"""计算闪烁颜色"""
|
||||||
# 根据时间间隔切换状态
|
# 根据时间间隔切换状态
|
||||||
interval = 1.0 / (self.color_speed * 2) # 闪烁间隔
|
speed = max(float(self.color_speed), 1e-4)
|
||||||
|
interval = 1.0 / (speed * 2.0) # 闪烁间隔
|
||||||
if int(self.time_accumulator / interval) % 2 == 0:
|
if int(self.time_accumulator / interval) % 2 == 0:
|
||||||
return Vec4(
|
return Vec4(
|
||||||
self.base_color.getX() * self.intensity,
|
self.base_color.getX() * self.intensity,
|
||||||
self.base_color.getY() * self.intensity,
|
self.base_color.getY() * self.intensity,
|
||||||
self.base_color.getZ() * self.intensity,
|
self.base_color.getZ() * self.intensity,
|
||||||
self.base_color.getW()
|
self.base_color.getW(),
|
||||||
)
|
)
|
||||||
else:
|
return Vec4(0.1, 0.1, 0.1, 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):
|
def set_color_parameters(self, speed=None, mode=None, base_color=None, intensity=None):
|
||||||
"""设置颜色参数"""
|
"""设置颜色参数"""
|
||||||
@ -129,7 +322,7 @@ class ColorChangerScript(ScriptBase):
|
|||||||
if mode is not None and mode in ["rainbow", "pulse", "fade", "strobe"]:
|
if mode is not None and mode in ["rainbow", "pulse", "fade", "strobe"]:
|
||||||
self.color_mode = mode
|
self.color_mode = mode
|
||||||
if base_color is not None:
|
if base_color is not None:
|
||||||
self.base_color = base_color
|
self.base_color = Vec4(base_color)
|
||||||
if intensity is not None:
|
if intensity is not None:
|
||||||
self.intensity = intensity
|
self.intensity = intensity
|
||||||
|
|
||||||
@ -143,16 +336,17 @@ class ColorChangerScript(ScriptBase):
|
|||||||
|
|
||||||
def reset_color(self):
|
def reset_color(self):
|
||||||
"""重置到原始颜色"""
|
"""重置到原始颜色"""
|
||||||
if self.original_color:
|
if self.original_color is None:
|
||||||
self.gameObject.setColor(self.original_color)
|
return
|
||||||
self.time_accumulator = 0.0
|
self._apply_color(self.original_color)
|
||||||
self.log("颜色已重置到原始值")
|
self.time_accumulator = 0.0
|
||||||
|
self.log("颜色已重置到原始值")
|
||||||
|
|
||||||
def set_solid_color(self, r=1.0, g=1.0, b=1.0, a=1.0):
|
def set_solid_color(self, r=1.0, g=1.0, b=1.0, a=1.0):
|
||||||
"""设置固定颜色"""
|
"""设置固定颜色"""
|
||||||
color = Vec4(r, g, b, a)
|
color = Vec4(r, g, b, a)
|
||||||
self.gameObject.setColor(color)
|
|
||||||
self.base_color = color
|
self.base_color = color
|
||||||
|
self._apply_color(color)
|
||||||
self.log(f"设置固定颜色: {color}")
|
self.log(f"设置固定颜色: {color}")
|
||||||
|
|
||||||
def on_destroy(self):
|
def on_destroy(self):
|
||||||
|
|||||||
@ -259,6 +259,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<label for="log-box">Message Log</label>
|
<label for="log-box">Message Log</label>
|
||||||
<textarea id="log-box" readonly></textarea>
|
<textarea id="log-box" readonly></textarea>
|
||||||
@ -280,6 +300,7 @@
|
|||||||
const logBox = document.getElementById("log-box");
|
const logBox = document.getElementById("log-box");
|
||||||
const nodeSelect = document.getElementById("node-select");
|
const nodeSelect = document.getElementById("node-select");
|
||||||
const clipInput = document.getElementById("clip-input");
|
const clipInput = document.getElementById("clip-input");
|
||||||
|
const scriptSelect = document.getElementById("script-select");
|
||||||
|
|
||||||
const pendingReplies = new Map();
|
const pendingReplies = new Map();
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@ -389,6 +410,26 @@
|
|||||||
return nodes;
|
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) => {
|
window.addEventListener("message", (event) => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || data.source !== "eg-webgl") {
|
if (!data || data.source !== "eg-webgl") {
|
||||||
@ -437,6 +478,7 @@
|
|||||||
document.getElementById("handshake-btn").addEventListener("click", () => {
|
document.getElementById("handshake-btn").addEventListener("click", () => {
|
||||||
runAction("handshake", async () => {
|
runAction("handshake", async () => {
|
||||||
const result = await sendHandshake();
|
const result = await sendHandshake();
|
||||||
|
await refreshSupportedScripts();
|
||||||
if (result.ready && !readyReceived) {
|
if (result.ready && !readyReceived) {
|
||||||
await refreshNodes();
|
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", () => {
|
viewerFrame.addEventListener("load", () => {
|
||||||
setStatus("Viewer iframe loaded. Click Handshake to start.");
|
setStatus("Viewer iframe loaded. Click Handshake to start.");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,6 +35,18 @@ viewer.nodes.setTransform(
|
|||||||
{ coordSystem: "panda", space: "local" },
|
{ 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) => {
|
viewer.events.on("animationFinished", (payload) => {
|
||||||
console.log("animation finished", payload);
|
console.log("animation finished", payload);
|
||||||
});</code></pre>
|
});</code></pre>
|
||||||
@ -56,14 +68,18 @@ iframe.contentWindow.postMessage({
|
|||||||
source: "eg-frontend",
|
source: "eg-frontend",
|
||||||
type: "command",
|
type: "command",
|
||||||
id: "cmd-1",
|
id: "cmd-1",
|
||||||
command: "animation.play",
|
command: "scripts.attach",
|
||||||
payload: {
|
payload: {
|
||||||
selector: { publicId: "model:asset-guid:root" },
|
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>
|
}, 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>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>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>
|
<p><strong>Demo page:</strong> after export, open <code>frontend_demo.html</code> to see a working iframe host example.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@ -1597,6 +1597,142 @@ function normalizeScriptName(name) {
|
|||||||
return canonicalizeNameSegment(text);
|
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) {
|
function markObjectTransformDirty(obj) {
|
||||||
if (!obj || !obj.isObject3D) return;
|
if (!obj || !obj.isObject3D) return;
|
||||||
obj.updateMatrix();
|
obj.updateMatrix();
|
||||||
@ -2006,6 +2142,7 @@ function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, own
|
|||||||
const withOwnerState = (state) => ({
|
const withOwnerState = (state) => ({
|
||||||
ownerId: entry?.id || "",
|
ownerId: entry?.id || "",
|
||||||
ownerPublicId: entry?.publicId || "",
|
ownerPublicId: entry?.publicId || "",
|
||||||
|
params: cloneJsonSafe(params, {}),
|
||||||
...state,
|
...state,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2235,10 +2372,9 @@ function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, own
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScriptRuntimeStates(THREE, nodes, nodeMap) {
|
function buildScriptRuntimeStates(THREE, nodes, nodeMap, nodeNameLookup = buildNodeNameLookup(nodeMap)) {
|
||||||
const runtimes = [];
|
const runtimes = [];
|
||||||
let unsupportedCount = 0;
|
let unsupportedCount = 0;
|
||||||
const nodeNameLookup = buildNodeNameLookup(nodeMap);
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const entry = node;
|
const entry = node;
|
||||||
@ -2270,6 +2406,217 @@ function buildScriptRuntimeStates(THREE, nodes, nodeMap) {
|
|||||||
return { runtimes, unsupportedCount };
|
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) {
|
function makeRuntimeEntry(node, obj) {
|
||||||
return {
|
return {
|
||||||
id: String(node?.id || ""),
|
id: String(node?.id || ""),
|
||||||
@ -2344,11 +2691,17 @@ function buildScriptStates(entry) {
|
|||||||
const name = String(scriptCfg?.name || "");
|
const name = String(scriptCfg?.name || "");
|
||||||
const key = normalizeScriptName(name);
|
const key = normalizeScriptName(name);
|
||||||
const runtimeState = runtimeByKey.get(key);
|
const runtimeState = runtimeByKey.get(key);
|
||||||
|
const descriptor = getWebSupportedScriptDescriptor(name || key);
|
||||||
out.push({
|
out.push({
|
||||||
name,
|
name,
|
||||||
key,
|
key,
|
||||||
enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true),
|
enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true),
|
||||||
supported: !!runtimeState,
|
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) {
|
if (runtimeState) {
|
||||||
runtimeByKey.delete(key);
|
runtimeByKey.delete(key);
|
||||||
@ -2356,11 +2709,19 @@ function buildScriptStates(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const runtimeState of runtimeByKey.values()) {
|
for (const runtimeState of runtimeByKey.values()) {
|
||||||
|
const runtimeName = String(runtimeState?.name || "");
|
||||||
|
const runtimeKey = normalizeScriptName(runtimeName || runtimeState?.key || "");
|
||||||
|
const descriptor = getWebSupportedScriptDescriptor(runtimeName || runtimeKey);
|
||||||
out.push({
|
out.push({
|
||||||
name: String(runtimeState?.name || ""),
|
name: runtimeName,
|
||||||
key: normalizeScriptName(runtimeState?.name || runtimeState?.key || ""),
|
key: runtimeKey,
|
||||||
enabled: !!runtimeState.enabled,
|
enabled: !!runtimeState.enabled,
|
||||||
supported: true,
|
supported: true,
|
||||||
|
webSupported: !!descriptor,
|
||||||
|
params: cloneJsonSafe(runtimeState?.params ?? {}, {}),
|
||||||
|
file: "",
|
||||||
|
projectRelativePath: "",
|
||||||
|
scriptGuid: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2410,6 +2771,7 @@ function buildSceneInfo(runtime) {
|
|||||||
environment: cloneJsonSafe(runtime?.manifest?.environment, {}),
|
environment: cloneJsonSafe(runtime?.manifest?.environment, {}),
|
||||||
nodeCount: runtime?.nodeOrder?.length || 0,
|
nodeCount: runtime?.nodeOrder?.length || 0,
|
||||||
unsupportedScriptCount: runtime?.unsupportedScriptCount || 0,
|
unsupportedScriptCount: runtime?.unsupportedScriptCount || 0,
|
||||||
|
webSupportedScripts: listWebSupportedScripts(),
|
||||||
nodes: (runtime?.nodeOrder || []).map((entry) => buildNodeSummary(entry)),
|
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: {
|
events: {
|
||||||
on(eventName, handler) {
|
on(eventName, handler) {
|
||||||
return runtime.eventHub.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.stop", (payload) => api.animation.stop(payload?.selector, payload?.options ?? {})],
|
||||||
["animation.seek", (payload) => api.animation.seek(payload?.selector, payload?.time)],
|
["animation.seek", (payload) => api.animation.seek(payload?.selector, payload?.time)],
|
||||||
["animation.setSpeed", (payload) => api.animation.setSpeed(payload?.selector, payload?.speed)],
|
["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 = "";
|
let trustedOrigin = "";
|
||||||
@ -2974,9 +3372,12 @@ async function bootstrap() {
|
|||||||
nodesById: new Map(),
|
nodesById: new Map(),
|
||||||
nodesByPublicId: new Map(),
|
nodesByPublicId: new Map(),
|
||||||
nodesByName: new Map(),
|
nodesByName: new Map(),
|
||||||
|
nodeMap: new Map(),
|
||||||
|
nodeNameLookup: new Map(),
|
||||||
eventHub: createEventHub(),
|
eventHub: createEventHub(),
|
||||||
readyDeferred: makeDeferred(),
|
readyDeferred: makeDeferred(),
|
||||||
animationControllers: [],
|
animationControllers: [],
|
||||||
|
scriptRuntimes: [],
|
||||||
unsupportedScriptCount: 0,
|
unsupportedScriptCount: 0,
|
||||||
isReady: false,
|
isReady: false,
|
||||||
bridge: null,
|
bridge: null,
|
||||||
@ -3088,6 +3489,7 @@ async function bootstrap() {
|
|||||||
nodeMap.set(String(node.id || ""), obj);
|
nodeMap.set(String(node.id || ""), obj);
|
||||||
const entry = makeRuntimeEntry(node, obj);
|
const entry = makeRuntimeEntry(node, obj);
|
||||||
registerRuntimeEntry(runtime, entry);
|
registerRuntimeEntry(runtime, entry);
|
||||||
|
runtime.nodeMap.set(String(node.id || ""), obj);
|
||||||
|
|
||||||
if (node.kind === "model") {
|
if (node.kind === "model") {
|
||||||
const modelUri = node.model?.uri;
|
const modelUri = node.model?.uri;
|
||||||
@ -3166,8 +3568,10 @@ async function bootstrap() {
|
|||||||
const composer = bloomSetup.composer;
|
const composer = bloomSetup.composer;
|
||||||
runtime.composer = 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;
|
const scriptStates = scriptRuntime.runtimes;
|
||||||
|
runtime.scriptRuntimes = scriptStates;
|
||||||
runtime.unsupportedScriptCount = scriptRuntime.unsupportedCount;
|
runtime.unsupportedScriptCount = scriptRuntime.unsupportedCount;
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
@ -3207,7 +3611,7 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
scene.updateMatrixWorld(true);
|
scene.updateMatrixWorld(true);
|
||||||
for (const state of scriptStates) {
|
for (const state of runtime.scriptRuntimes) {
|
||||||
try {
|
try {
|
||||||
state.update(dt);
|
state.update(dt);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -205,7 +205,7 @@ class AppActions:
|
|||||||
|
|
||||||
def _mount_script_to_selected(self, script_name):
|
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():
|
if not selected_node or selected_node.isEmpty():
|
||||||
self.add_error_message("请先选择一个对象")
|
self.add_error_message("请先选择一个对象")
|
||||||
@ -213,9 +213,22 @@ class AppActions:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if hasattr(self, 'script_manager') and self.script_manager:
|
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)
|
script_component = self.script_manager.add_script_to_object(selected_node, script_name)
|
||||||
if script_component:
|
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()}")
|
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()}")
|
print(f"[脚本系统] 挂载脚本: {script_name} -> {selected_node.getName()}")
|
||||||
else:
|
else:
|
||||||
self.add_error_message(f"挂载脚本 {script_name} 失败")
|
self.add_error_message(f"挂载脚本 {script_name} 失败")
|
||||||
@ -227,7 +240,7 @@ class AppActions:
|
|||||||
|
|
||||||
def _unmount_script_from_selected(self, script_name):
|
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():
|
if not selected_node or selected_node.isEmpty():
|
||||||
self.add_error_message("请先选择一个对象")
|
self.add_error_message("请先选择一个对象")
|
||||||
|
|||||||
@ -72,6 +72,49 @@ class PanelDelegates:
|
|||||||
return runtime_node
|
return runtime_node
|
||||||
return self._get_selection_source_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):
|
def _get_selection_key(self):
|
||||||
ssbo_editor = getattr(self, "ssbo_editor", None)
|
ssbo_editor = getattr(self, "ssbo_editor", None)
|
||||||
if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection():
|
if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection():
|
||||||
|
|||||||
@ -260,8 +260,10 @@ class ScriptPanels:
|
|||||||
def _draw_script_mounting_group(self):
|
def _draw_script_mounting_group(self):
|
||||||
"""绘制脚本挂载组"""
|
"""绘制脚本挂载组"""
|
||||||
if imgui.collapsing_header("脚本挂载"):
|
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():
|
if selected_node and not selected_node.isEmpty():
|
||||||
imgui.text("选中对象:")
|
imgui.text("选中对象:")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user