From b4fd0c2e686cbbebb41770a86f443ccf13f6d124 Mon Sep 17 00:00:00 2001 From: Rowland <975945824@qq.com> Date: Tue, 21 Apr 2026 15:57:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- imgui.ini | 15 +- main.py | 2 +- project/webgl_packager.py | 20 +- scripts/ColorChangerScript.py | 514 ++++++++++++++++++++--------- templates/webgl/frontend_demo.html | 63 ++++ templates/webgl/index.html | 20 +- templates/webgl/viewer.js | 416 ++++++++++++++++++++++- ui/panels/app_actions.py | 17 +- ui/panels/panel_delegates.py | 43 +++ ui/panels/script_panels.py | 6 +- 10 files changed, 933 insertions(+), 183 deletions(-) diff --git a/imgui.ini b/imgui.ini index 59fef0d2..0c0c1e4f 100644 --- a/imgui.ini +++ b/imgui.ini @@ -37,9 +37,9 @@ DockId=0x00000007,0 [Window][属性面板] Pos=1504,20 -Size=346,996 +Size=346,498 Collapsed=0 -DockId=0x00000002,0 +DockId=0x00000003,0 [Window][控制台] Pos=341,617 @@ -48,9 +48,10 @@ Collapsed=0 DockId=0x00000006,1 [Window][脚本管理] -Pos=1950,20 -Size=610,995 +Pos=1504,520 +Size=346,496 Collapsed=0 +DockId=0x00000004,0 [Window][中文显示测试] Pos=60,60 @@ -152,7 +153,7 @@ Collapsed=0 Pos=2214,20 Size=346,1331 Collapsed=0 -DockId=0x00000002,1 +DockId=0x00000003,1 [Window][LUI测试控制面板] Pos=6,10 @@ -233,5 +234,7 @@ DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1850,996 Split=X DockNode ID=0x0000000D Parent=0x00000005 SizeRef=1318,383 HiddenTabBar=1 Selected=0x43A39006 DockNode ID=0x0000000E Parent=0x00000005 SizeRef=1318,363 CentralNode=1 Selected=0xE0015051 DockNode ID=0x00000006 Parent=0x00000008 SizeRef=2048,399 Selected=0x3A2E05C3 - DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=346,1012 Selected=0x5DB6FF37 + DockNode ID=0x00000002 Parent=0x08BD597D SizeRef=346,1012 Split=Y Selected=0x5DB6FF37 + DockNode ID=0x00000003 Parent=0x00000002 SizeRef=346,498 Selected=0x5DB6FF37 + DockNode ID=0x00000004 Parent=0x00000002 SizeRef=346,496 Selected=0x3188AB8D diff --git a/main.py b/main.py index cdf02a03..8276a90a 100644 --- a/main.py +++ b/main.py @@ -333,7 +333,7 @@ class MyWorld(PanelDelegates, CoreWorld): "scene_tree": True, "property": True, "console": True, - "script": not self.use_ssbo_mouse_picking, + "script": True, "toolbar": True, "resources": True, "web": False, diff --git a/project/webgl_packager.py b/project/webgl_packager.py index 32ac74c0..ad6e4b27 100644 --- a/project/webgl_packager.py +++ b/project/webgl_packager.py @@ -1374,6 +1374,7 @@ class WebGLPackager: out: List[Dict[str, Any]] = [] loader = getattr(script_manager, "loader", None) find_script_file = getattr(loader, "find_script_file", None) if loader else None + build_script_reference = getattr(script_manager, "build_script_reference", None) for component in script_components: script_instance = getattr(component, "script_instance", None) @@ -1395,7 +1396,17 @@ class WebGLPackager: "name": script_name, "enabled": bool(getattr(component, "enabled", True)), } - if script_file: + if callable(build_script_reference): + try: + reference = build_script_reference(script_name, script_file) or {} + except Exception: + reference = {} + if isinstance(reference, dict): + for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"): + value = str(reference.get(key, "") or "").strip() + if value: + entry[key] = value + elif script_file: entry["file"] = script_file params = self._serialize_script_instance_params(script_instance) @@ -1436,9 +1447,10 @@ class WebGLPackager: "name": script_name, "enabled": bool(item.get("enabled", True)), } - script_file = str(item.get("file", "")).strip() - if script_file: - entry["file"] = script_file + for key in ("file", "project_relative_path", "script_guid", "relative_path", "path"): + value = str(item.get(key, "") or "").strip() + if value: + entry[key] = value params = item.get("params") if isinstance(params, dict) and params: entry["params"] = params diff --git a/scripts/ColorChangerScript.py b/scripts/ColorChangerScript.py index ac302f70..1579119c 100644 --- a/scripts/ColorChangerScript.py +++ b/scripts/ColorChangerScript.py @@ -1,160 +1,354 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -颜色变化脚本 - 让对象颜色产生循环变化 -""" - -from core.script_system import ScriptBase -from panda3d.core import Vec4 -import math - -class ColorChangerScript(ScriptBase): - """颜色变化脚本类""" - - def __init__(self): - super().__init__() - - # 颜色参数 - self.color_speed = 1.0 # 颜色变化速度 (周期/秒) - self.color_mode = "rainbow" # 颜色模式: "rainbow", "pulse", "fade", "strobe" - self.base_color = Vec4(1, 1, 1, 1) # 基础颜色 - self.intensity = 1.0 # 颜色强度 - - # 内部变量 - self.time_accumulator = 0.0 # 时间累积器 - self.original_color = None # 原始颜色 - self.is_changing = True # 是否正在变化 - self.strobe_state = False # 闪烁状态 - - def start(self): - """脚本开始时调用""" - self.log("颜色变化脚本启动!") - self.log(f"颜色参数: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}") - - # 记录原始颜色 - self.original_color = self.gameObject.getColor() - self.log(f"原始颜色: {self.original_color}") - - def update(self, dt): - """每帧更新""" - if not self.is_changing: - return - - # 累积时间 - self.time_accumulator += dt - - # 根据模式计算新颜色 - if self.color_mode == "rainbow": - new_color = self._calculate_rainbow_color() - elif self.color_mode == "pulse": - new_color = self._calculate_pulse_color() - elif self.color_mode == "fade": - new_color = self._calculate_fade_color() - elif self.color_mode == "strobe": - new_color = self._calculate_strobe_color() - else: - new_color = self.base_color - - # 应用颜色 - self.gameObject.setColor(new_color) - - def _calculate_rainbow_color(self): - """计算彩虹颜色""" - # 使用HSV到RGB的转换创建彩虹效果 - hue = (self.time_accumulator * self.color_speed) % 1.0 - - # 简单的HSV到RGB转换 - i = int(hue * 6.0) - f = (hue * 6.0) - i - p = 0.0 - q = 1.0 - f - t = f - - if i % 6 == 0: - r, g, b = 1.0, t, p - elif i % 6 == 1: - r, g, b = q, 1.0, p - elif i % 6 == 2: - r, g, b = p, 1.0, t - elif i % 6 == 3: - r, g, b = p, q, 1.0 - elif i % 6 == 4: - r, g, b = t, p, 1.0 - else: - r, g, b = 1.0, p, q - - return Vec4(r * self.intensity, g * self.intensity, b * self.intensity, 1.0) - - def _calculate_pulse_color(self): - """计算脉冲颜色""" - pulse = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0 - multiplier = pulse * self.intensity - return Vec4( - self.base_color.getX() * multiplier, - self.base_color.getY() * multiplier, - self.base_color.getZ() * multiplier, - self.base_color.getW() - ) - - def _calculate_fade_color(self): - """计算淡入淡出颜色""" - fade = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0 - alpha = fade * self.intensity - return Vec4( - self.base_color.getX(), - self.base_color.getY(), - self.base_color.getZ(), - alpha - ) - - def _calculate_strobe_color(self): - """计算闪烁颜色""" - # 根据时间间隔切换状态 - interval = 1.0 / (self.color_speed * 2) # 闪烁间隔 - if int(self.time_accumulator / interval) % 2 == 0: - return Vec4( - self.base_color.getX() * self.intensity, - self.base_color.getY() * self.intensity, - self.base_color.getZ() * self.intensity, - self.base_color.getW() - ) - else: - return Vec4(0.1, 0.1, 0.1, self.base_color.getW()) # 暗色状态 - - def set_color_parameters(self, speed=None, mode=None, base_color=None, intensity=None): - """设置颜色参数""" - if speed is not None: - self.color_speed = speed - if mode is not None and mode in ["rainbow", "pulse", "fade", "strobe"]: - self.color_mode = mode - if base_color is not None: - self.base_color = base_color - if intensity is not None: - self.intensity = intensity - - self.log(f"颜色参数更新: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}") - - def toggle_color_change(self): - """切换颜色变化状态""" - self.is_changing = not self.is_changing - status = "恢复" if self.is_changing else "暂停" - self.log(f"颜色变化{status}") - - def reset_color(self): - """重置到原始颜色""" - if self.original_color: - self.gameObject.setColor(self.original_color) - self.time_accumulator = 0.0 - self.log("颜色已重置到原始值") - - def set_solid_color(self, r=1.0, g=1.0, b=1.0, a=1.0): - """设置固定颜色""" - color = Vec4(r, g, b, a) - self.gameObject.setColor(color) - self.base_color = color - self.log(f"设置固定颜色: {color}") - - def on_destroy(self): - """脚本销毁时调用""" - self.log("颜色变化脚本停止") \ No newline at end of file +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +颜色变化脚本 - 让对象颜色产生循环变化 +""" + +from core.script_system import ScriptBase +from panda3d.core import TransparencyAttrib, Vec4 +import math + + +class ColorChangerScript(ScriptBase): + """颜色变化脚本类""" + + def __init__(self): + super().__init__() + + # 颜色参数 + self.color_speed = 1.0 # 颜色变化速度 (周期/秒) + self.color_mode = "rainbow" # 颜色模式: "rainbow", "pulse", "fade", "strobe" + self.base_color = Vec4(1, 1, 1, 1) # 基础颜色 + self.intensity = 1.0 # 颜色强度 + + # 内部变量 + self.time_accumulator = 0.0 # 时间累积器 + self.original_color = None # 原始颜色 + self.is_changing = True # 是否正在变化 + self.strobe_state = False # 闪烁状态 + + # 渲染目标缓存(避免每帧重新遍历) + self._target_nodes = [] + self._material_targets = [] + self._property_helpers = None + + def _is_node_valid(self, node): + if not node: + return False + try: + return not node.isEmpty() + except Exception: + try: + return not node.is_empty() + except Exception: + return False + + def _collect_target_nodes(self): + """收集需要同步颜色的节点(根 + GeomNode 后代)""" + targets = [] + if self._is_node_valid(self.gameObject): + targets.append(self.gameObject) + try: + for geom_np in self.gameObject.findAllMatches("**/+GeomNode"): + if self._is_node_valid(geom_np): + targets.append(geom_np) + except Exception: + pass + return targets + + @staticmethod + def _clone_material(material): + try: + if hasattr(material, "makeCopy"): + return material.makeCopy() + except Exception: + pass + return material + + @staticmethod + def _set_material_base_color(material, color): + color_vec = Vec4(color) + try: + if hasattr(material, "set_base_color"): + material.set_base_color(color_vec) + return + if hasattr(material, "setBaseColor"): + material.setBaseColor(color_vec) + return + if hasattr(material, "setDiffuse"): + material.setDiffuse(color_vec) + except Exception: + pass + + def _prepare_material_targets(self): + """确保每个目标节点都有可编辑材质实例,避免改到共享材质。""" + helper = self._property_helpers + targets = [] + seen = set() + for node in self._target_nodes: + if not self._is_node_valid(node): + continue + try: + materials = [] + # 优先走编辑器现有材质通路:可覆盖 GeomState 中的材质而不仅是 NodePath 材质。 + if helper: + try: + if hasattr(helper, "_ensure_unique_materials_for_node"): + helper._ensure_unique_materials_for_node(node) + except Exception: + pass + try: + if hasattr(helper, "_get_node_materials"): + materials = list(helper._get_node_materials(node) or []) + except Exception: + materials = [] + + if not materials: + if node.hasMaterial(): + mat = node.getMaterial() + if mat is not None: + materials = [mat] + + if not materials: + continue + + for mat in materials: + if mat is None: + continue + material_key = id(mat) + pair_key = (id(node), material_key) + if pair_key in seen: + continue + seen.add(pair_key) + targets.append((node, mat)) + except Exception: + continue + self._material_targets = targets + + def _apply_color(self, color): + """同时更新 NodePath 颜色、ColorScale、材质颜色与透明度状态。""" + if not self._is_node_valid(self.gameObject): + return + + color = Vec4(color) + alpha = float(max(0.0, min(1.0, color.getW()))) + rgba_tuple = (float(color.getX()), float(color.getY()), float(color.getZ()), alpha) + + for node in self._target_nodes: + if not self._is_node_valid(node): + continue + try: + node.setColor(*rgba_tuple) + except Exception: + pass + try: + node.setColorScale(*rgba_tuple) + except Exception: + pass + try: + node.setShaderInput("material_base_color", rgba_tuple) + except Exception: + pass + try: + node.setShaderInput("material_opacity", alpha) + except Exception: + pass + + if alpha < 0.999: + try: + node.setTransparency(TransparencyAttrib.M_alpha) + except Exception: + pass + try: + node.setAlphaScale(alpha) + except Exception: + pass + else: + try: + node.setTransparency(TransparencyAttrib.M_none) + except Exception: + pass + try: + node.setAlphaScale(1.0) + except Exception: + pass + + for node, material in self._material_targets: + if not self._is_node_valid(node): + continue + try: + if self._property_helpers and hasattr(self._property_helpers, "_set_material_base_color"): + self._property_helpers._set_material_base_color(material, rgba_tuple) + else: + self._set_material_base_color(material, rgba_tuple) + except Exception: + pass + + try: + if self._property_helpers and hasattr(self._property_helpers, "_sync_material_node_runtime"): + # 刷新 runtime 材质状态,但避免在每帧触发 source->runtime 全量重建。 + self._property_helpers._sync_material_node_runtime( + node, + material, + refresh_ssbo_runtime=False, + ) + else: + node.setMaterial(material, 1) + except Exception: + pass + + try: + if self._property_helpers and hasattr(self._property_helpers, "_invalidate_material_render_cache"): + self._property_helpers._invalidate_material_render_cache() + except Exception: + pass + + def start(self): + """脚本开始时调用""" + self.log("颜色变化脚本启动!") + self.log(f"颜色参数: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}") + + if not self._is_node_valid(self.gameObject): + self.log("警告: 挂载对象不可用,颜色变化不会生效") + return + + # 记录原始颜色 + try: + self.original_color = self.gameObject.getColor() + except Exception: + self.original_color = Vec4(1, 1, 1, 1) + self.log(f"原始颜色: {self.original_color}") + + self._property_helpers = getattr(self.world, "property_helpers", None) + self._target_nodes = self._collect_target_nodes() + self._prepare_material_targets() + self.log( + f"颜色目标节点: {len(self._target_nodes)},材质目标: {len(self._material_targets)}" + ) + + def update(self, dt): + """每帧更新""" + if not self.is_changing: + return + if not self._is_node_valid(self.gameObject): + return + + # 累积时间 + self.time_accumulator += dt + + # 根据模式计算新颜色 + if self.color_mode == "rainbow": + new_color = self._calculate_rainbow_color() + elif self.color_mode == "pulse": + new_color = self._calculate_pulse_color() + elif self.color_mode == "fade": + new_color = self._calculate_fade_color() + elif self.color_mode == "strobe": + new_color = self._calculate_strobe_color() + else: + new_color = self.base_color + + # 应用颜色 + self._apply_color(new_color) + + def _calculate_rainbow_color(self): + """计算彩虹颜色""" + # 使用HSV到RGB的转换创建彩虹效果 + hue = (self.time_accumulator * self.color_speed) % 1.0 + + # 简单的HSV到RGB转换 + i = int(hue * 6.0) + f = (hue * 6.0) - i + p = 0.0 + q = 1.0 - f + t = f + + if i % 6 == 0: + r, g, b = 1.0, t, p + elif i % 6 == 1: + r, g, b = q, 1.0, p + elif i % 6 == 2: + r, g, b = p, 1.0, t + elif i % 6 == 3: + r, g, b = p, q, 1.0 + elif i % 6 == 4: + r, g, b = t, p, 1.0 + else: + r, g, b = 1.0, p, q + + return Vec4(r * self.intensity, g * self.intensity, b * self.intensity, 1.0) + + def _calculate_pulse_color(self): + """计算脉冲颜色""" + pulse = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0 + multiplier = pulse * self.intensity + return Vec4( + self.base_color.getX() * multiplier, + self.base_color.getY() * multiplier, + self.base_color.getZ() * multiplier, + self.base_color.getW(), + ) + + def _calculate_fade_color(self): + """计算淡入淡出颜色""" + fade = (math.sin(self.time_accumulator * self.color_speed * 2 * math.pi) + 1.0) / 2.0 + alpha = fade * self.intensity + return Vec4( + self.base_color.getX(), + self.base_color.getY(), + self.base_color.getZ(), + alpha, + ) + + def _calculate_strobe_color(self): + """计算闪烁颜色""" + # 根据时间间隔切换状态 + speed = max(float(self.color_speed), 1e-4) + interval = 1.0 / (speed * 2.0) # 闪烁间隔 + if int(self.time_accumulator / interval) % 2 == 0: + return Vec4( + self.base_color.getX() * self.intensity, + self.base_color.getY() * self.intensity, + self.base_color.getZ() * self.intensity, + self.base_color.getW(), + ) + return Vec4(0.1, 0.1, 0.1, self.base_color.getW()) # 暗色状态 + + def set_color_parameters(self, speed=None, mode=None, base_color=None, intensity=None): + """设置颜色参数""" + if speed is not None: + self.color_speed = speed + if mode is not None and mode in ["rainbow", "pulse", "fade", "strobe"]: + self.color_mode = mode + if base_color is not None: + self.base_color = Vec4(base_color) + if intensity is not None: + self.intensity = intensity + + self.log(f"颜色参数更新: 速度={self.color_speed}, 模式={self.color_mode}, 强度={self.intensity}") + + def toggle_color_change(self): + """切换颜色变化状态""" + self.is_changing = not self.is_changing + status = "恢复" if self.is_changing else "暂停" + self.log(f"颜色变化{status}") + + def reset_color(self): + """重置到原始颜色""" + if self.original_color is None: + return + self._apply_color(self.original_color) + self.time_accumulator = 0.0 + self.log("颜色已重置到原始值") + + def set_solid_color(self, r=1.0, g=1.0, b=1.0, a=1.0): + """设置固定颜色""" + color = Vec4(r, g, b, a) + self.base_color = color + self._apply_color(color) + self.log(f"设置固定颜色: {color}") + + def on_destroy(self): + """脚本销毁时调用""" + self.log("颜色变化脚本停止") diff --git a/templates/webgl/frontend_demo.html b/templates/webgl/frontend_demo.html index 78091244..725cc2cb 100644 --- a/templates/webgl/frontend_demo.html +++ b/templates/webgl/frontend_demo.html @@ -259,6 +259,26 @@ +
+
+ + +
+
+ + +
+
这是 Web 端支持脚本示例。浏览器不会直接执行 Python 文件,而是按脚本名挂载对应的 Web 适配逻辑。
+
+
@@ -280,6 +300,7 @@ const logBox = document.getElementById("log-box"); const nodeSelect = document.getElementById("node-select"); const clipInput = document.getElementById("clip-input"); + const scriptSelect = document.getElementById("script-select"); const pendingReplies = new Map(); let sequence = 0; @@ -389,6 +410,26 @@ return nodes; } + async function refreshSupportedScripts() { + const supported = await sendCommand("scripts.listSupported"); + if (!Array.isArray(supported) || supported.length === 0) { + return supported; + } + + const currentValue = scriptSelect.value; + scriptSelect.innerHTML = ""; + for (const script of supported) { + const option = document.createElement("option"); + option.value = script.name || script.key || ""; + option.textContent = (script.name || script.key || "script") + (script.summary ? " - " + script.summary : ""); + scriptSelect.appendChild(option); + } + if (currentValue) { + scriptSelect.value = currentValue; + } + return supported; + } + window.addEventListener("message", (event) => { const data = event.data; if (!data || data.source !== "eg-webgl") { @@ -437,6 +478,7 @@ document.getElementById("handshake-btn").addEventListener("click", () => { runAction("handshake", async () => { const result = await sendHandshake(); + await refreshSupportedScripts(); if (result.ready && !readyReceived) { await refreshNodes(); } @@ -571,6 +613,27 @@ }); }); + document.getElementById("attach-script-btn").addEventListener("click", () => { + runAction("scripts.attach", async () => { + const scriptName = scriptSelect.value; + return sendCommand("scripts.attach", { + selector: getSelectedSelector(), + script: { + name: scriptName, + }, + }); + }); + }); + + document.getElementById("detach-script-btn").addEventListener("click", () => { + runAction("scripts.detach", async () => { + return sendCommand("scripts.detach", { + selector: getSelectedSelector(), + name: scriptSelect.value, + }); + }); + }); + viewerFrame.addEventListener("load", () => { setStatus("Viewer iframe loaded. Click Handshake to start."); }); diff --git a/templates/webgl/index.html b/templates/webgl/index.html index 361525c9..d8af10c4 100644 --- a/templates/webgl/index.html +++ b/templates/webgl/index.html @@ -35,6 +35,18 @@ viewer.nodes.setTransform( { coordSystem: "panda", space: "local" }, ); +const supportedScripts = viewer.scripts.listSupported(); +console.log("web supported scripts", supportedScripts); + +viewer.scripts.attach({ publicId: hero.publicId }, { + name: "MoverScript", + params: { + move_axis: "x", + move_distance: 3, + move_speed: 1.5, + }, +}); + viewer.events.on("animationFinished", (payload) => { console.log("animation finished", payload); }); @@ -56,14 +68,18 @@ iframe.contentWindow.postMessage({ source: "eg-frontend", type: "command", id: "cmd-1", - command: "animation.play", + command: "scripts.attach", payload: { selector: { publicId: "model:asset-guid:root" }, - options: { clip: "Idle", loop: true }, + script: { + name: "RotatorScript", + params: { rotation_speed_y: 45 }, + }, }, }, iframe.src ? new URL(iframe.src).origin : "*");

Message protocol: commands use { source: "eg-frontend", id, type: "command", command, payload }. Responses/events use { source: "eg-webgl", type, replyTo?, event?, ok?, result?, error? }.

Events: ready, error, animationFinished, stateChanged.

+

Script note: exported WebGL can read existing script metadata, but browser-side runtime only executes Web-supported script adapters, not arbitrary Python files.

Demo page: after export, open frontend_demo.html to see a working iframe host example.

diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js index df568e0c..0b9aad25 100644 --- a/templates/webgl/viewer.js +++ b/templates/webgl/viewer.js @@ -1597,6 +1597,142 @@ function normalizeScriptName(name) { return canonicalizeNameSegment(text); } +function buildSupportedScriptCatalog() { + const descriptors = [ + { + key: "moverscript", + name: "MoverScript", + aliases: ["mover"], + summary: "Move the node back and forth along one Panda axis.", + defaultParams: { + move_axis: "x", + move_distance: 5.0, + move_speed: 2.0, + current_direction: 1, + current_distance: 0.0, + is_moving: true, + }, + }, + { + key: "rotatorscript", + name: "RotatorScript", + aliases: ["rotator"], + summary: "Rotate the node continuously around the Y axis in Web view.", + defaultParams: { + rotation_speed_y: 30.0, + is_rotating: true, + }, + }, + { + key: "scalerscript", + name: "ScalerScript", + aliases: ["scaler"], + summary: "Animate the node scale with a sine wave.", + defaultParams: { + base_scale: 1.0, + scale_amplitude: 0.3, + scale_speed: 2.0, + uniform_scale: true, + time_accumulator: 0.0, + is_scaling: true, + }, + }, + { + key: "colorchangerscript", + name: "ColorChangerScript", + aliases: ["colorchanger"], + summary: "Animate mesh material color or opacity over time.", + defaultParams: { + color_speed: 1.0, + color_mode: "rainbow", + base_color: [1, 1, 1, 1], + intensity: 1.0, + time_accumulator: 0.0, + is_changing: true, + }, + }, + { + key: "bouncerscript", + name: "BouncerScript", + aliases: ["bouncer"], + summary: "Bounce the node vertically using a simple waveform.", + defaultParams: { + jump_height: 2.0, + jump_speed: 3.0, + bounce_type: "sine", + time_accumulator: 0.0, + is_bouncing: true, + bounce_direction: 1, + }, + }, + { + key: "followerscript", + name: "FollowerScript", + aliases: ["follower"], + summary: "Follow another exported node selected by id/publicId/name reference.", + defaultParams: { + follow_speed: 5.0, + follow_distance: 2.0, + is_following: true, + target_ref: null, + }, + }, + { + key: "comboanimatorscript", + name: "ComboAnimatorScript", + aliases: ["comboanimator"], + summary: "Rotate and bounce the node using a built-in Web combo animation.", + defaultParams: { + time: 0.0, + is_active: true, + }, + }, + ]; + + const catalog = new Map(); + for (const descriptor of descriptors) { + const aliases = Array.from(new Set([ + descriptor.key, + descriptor.name, + ...(Array.isArray(descriptor.aliases) ? descriptor.aliases : []), + ].map((value) => normalizeScriptName(value)).filter((value) => !!value))); + const normalized = { + key: normalizeScriptName(descriptor.key || descriptor.name || ""), + name: String(descriptor.name || descriptor.key || ""), + aliases, + summary: String(descriptor.summary || ""), + defaultParams: cloneJsonSafe(descriptor.defaultParams, {}), + }; + for (const alias of aliases) { + catalog.set(alias, normalized); + } + } + return catalog; +} + +const WEB_SUPPORTED_SCRIPT_CATALOG = buildSupportedScriptCatalog(); + +function getWebSupportedScriptDescriptor(scriptName) { + return WEB_SUPPORTED_SCRIPT_CATALOG.get(normalizeScriptName(scriptName)) || null; +} + +function listWebSupportedScripts() { + const out = []; + const seen = new Set(); + for (const descriptor of WEB_SUPPORTED_SCRIPT_CATALOG.values()) { + if (!descriptor?.key || seen.has(descriptor.key)) continue; + seen.add(descriptor.key); + out.push({ + key: descriptor.key, + name: descriptor.name, + aliases: Array.isArray(descriptor.aliases) ? descriptor.aliases.slice() : [], + summary: descriptor.summary || "", + defaultParams: cloneJsonSafe(descriptor.defaultParams, {}), + }); + } + return out; +} + function markObjectTransformDirty(obj) { if (!obj || !obj.isObject3D) return; obj.updateMatrix(); @@ -2006,6 +2142,7 @@ function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, own const withOwnerState = (state) => ({ ownerId: entry?.id || "", ownerPublicId: entry?.publicId || "", + params: cloneJsonSafe(params, {}), ...state, }); @@ -2235,10 +2372,9 @@ function createScriptState(THREE, scriptCfg, entry, nodeMap, nodeNameLookup, own return null; } -function buildScriptRuntimeStates(THREE, nodes, nodeMap) { +function buildScriptRuntimeStates(THREE, nodes, nodeMap, nodeNameLookup = buildNodeNameLookup(nodeMap)) { const runtimes = []; let unsupportedCount = 0; - const nodeNameLookup = buildNodeNameLookup(nodeMap); for (const node of nodes) { const entry = node; @@ -2270,6 +2406,217 @@ function buildScriptRuntimeStates(THREE, nodes, nodeMap) { return { runtimes, unsupportedCount }; } +function ensureDeclaredScripts(entry) { + if (!entry?.nodeData || typeof entry.nodeData !== "object") { + throw createViewerError("invalid_node_data", "Node data is unavailable for script operations", { + node: buildNodeSelectorPayload(entry), + }); + } + if (!Array.isArray(entry.nodeData.scripts)) { + entry.nodeData.scripts = []; + } + return entry.nodeData.scripts; +} + +function findDeclaredScriptIndex(entry, scriptNameOrKey) { + const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : []; + const wantedKey = normalizeScriptName(scriptNameOrKey); + if (!wantedKey) return -1; + for (let i = 0; i < declared.length; i += 1) { + const key = normalizeScriptName(declared[i]?.name || declared[i]?.key || ""); + if (key === wantedKey) { + return i; + } + } + return -1; +} + +function normalizeScriptConfigInput(scriptInput, fallbackName = "") { + if (typeof scriptInput === "string") { + const trimmed = String(scriptInput).trim(); + if (!trimmed) { + throw createViewerError("invalid_script_config", "Script name must not be empty"); + } + return { name: trimmed, enabled: true, params: {} }; + } + + if (!scriptInput || typeof scriptInput !== "object") { + throw createViewerError("invalid_script_config", "Script config must be a string or object"); + } + + const name = String(scriptInput.name || fallbackName || "").trim(); + if (!name) { + throw createViewerError("invalid_script_config", "Script config must include a name"); + } + + const params = (scriptInput.params && typeof scriptInput.params === "object") + ? cloneJsonSafe(scriptInput.params, {}) + : {}; + + const out = { + name, + params, + }; + + if (Object.prototype.hasOwnProperty.call(scriptInput, "enabled")) { + out.enabled = toBoolean(scriptInput.enabled, true); + } + + for (const key of ["file", "project_relative_path", "script_guid", "relative_path", "path"]) { + const value = String(scriptInput[key] || "").trim(); + if (value) { + out[key] = value; + } + } + + return out; +} + +function mergeScriptConfig(existingConfig, nextConfig) { + const existing = (existingConfig && typeof existingConfig === "object") ? existingConfig : {}; + const next = normalizeScriptConfigInput(nextConfig, existing?.name || ""); + return { + ...cloneJsonSafe(existing, {}), + ...next, + name: next.name || String(existing?.name || ""), + enabled: Object.prototype.hasOwnProperty.call(next, "enabled") + ? !!next.enabled + : toBoolean(existing?.enabled, true), + params: { + ...(existing?.params && typeof existing.params === "object" ? cloneJsonSafe(existing.params, {}) : {}), + ...(next?.params && typeof next.params === "object" ? cloneJsonSafe(next.params, {}) : {}), + }, + }; +} + +function refreshUnsupportedScriptStats(runtime) { + let unsupportedCount = 0; + for (const entry of runtime?.nodeOrder || []) { + const declared = Array.isArray(entry?.nodeData?.scripts) ? entry.nodeData.scripts : []; + entry.unsupportedScripts = []; + for (const scriptCfg of declared) { + if (getWebSupportedScriptDescriptor(scriptCfg?.name || scriptCfg?.key || "")) { + continue; + } + unsupportedCount += 1; + entry.unsupportedScripts.push(String(scriptCfg?.name || "")); + } + } + runtime.unsupportedScriptCount = unsupportedCount; + return unsupportedCount; +} + +function removeScriptRuntime(runtime, entry, scriptNameOrKey) { + const wantedKey = normalizeScriptName(scriptNameOrKey); + if (!wantedKey) return 0; + let removed = 0; + + if (Array.isArray(entry?.scriptStates)) { + for (let i = entry.scriptStates.length - 1; i >= 0; i -= 1) { + const state = entry.scriptStates[i]; + const stateKey = normalizeScriptName(state?.name || state?.key || ""); + if (stateKey === wantedKey) { + entry.scriptStates.splice(i, 1); + removed += 1; + } + } + } + + if (Array.isArray(runtime?.scriptRuntimes)) { + for (let i = runtime.scriptRuntimes.length - 1; i >= 0; i -= 1) { + const state = runtime.scriptRuntimes[i]; + const stateKey = normalizeScriptName(state?.name || state?.key || ""); + const sameOwner = String(state?.ownerId || "") === String(entry?.id || ""); + if (sameOwner && stateKey === wantedKey) { + runtime.scriptRuntimes.splice(i, 1); + } + } + } + + return removed; +} + +function instantiateScriptRuntime(THREE, runtime, entry, scriptCfg) { + const state = createScriptState( + THREE, + scriptCfg, + entry, + runtime?.nodeMap || new Map(), + runtime?.nodeNameLookup || buildNodeNameLookup(runtime?.nodeMap || new Map()), + entry?.name || entry?.id || "", + ); + if (!state) { + throw createViewerError("script_not_supported", "Script is not supported in the Web viewer", { + node: buildNodeSelectorPayload(entry), + script: scriptCfg?.name || "", + supportedScripts: listWebSupportedScripts(), + }); + } + entry.scriptStates.push(state); + if (Array.isArray(runtime?.scriptRuntimes)) { + runtime.scriptRuntimes.push(state); + } + refreshUnsupportedScriptStats(runtime); + return state; +} + +function attachScriptToEntry(THREE, runtime, entry, scriptInput) { + const nextConfig = normalizeScriptConfigInput(scriptInput); + const descriptor = getWebSupportedScriptDescriptor(nextConfig.name); + if (!descriptor) { + throw createViewerError("script_not_supported", "Requested script cannot run in the Web viewer", { + node: buildNodeSelectorPayload(entry), + script: nextConfig.name, + supportedScripts: listWebSupportedScripts(), + }); + } + + const declared = ensureDeclaredScripts(entry); + const existingIndex = findDeclaredScriptIndex(entry, nextConfig.name); + const mergedConfig = existingIndex >= 0 + ? mergeScriptConfig(declared[existingIndex], { ...nextConfig, name: descriptor.name }) + : mergeScriptConfig({ name: descriptor.name, enabled: true, params: descriptor.defaultParams }, nextConfig); + + if (existingIndex >= 0) { + declared.splice(existingIndex, 1, mergedConfig); + } else { + declared.push(mergedConfig); + } + + removeScriptRuntime(runtime, entry, descriptor.key); + instantiateScriptRuntime(THREE, runtime, entry, mergedConfig); + return mergedConfig; +} + +function detachScriptFromEntry(runtime, entry, scriptNameOrKey) { + const wantedKey = normalizeScriptName(scriptNameOrKey); + if (!wantedKey) { + throw createViewerError("invalid_script_config", "Script name must not be empty"); + } + + const declared = ensureDeclaredScripts(entry); + let removedDeclared = 0; + for (let i = declared.length - 1; i >= 0; i -= 1) { + const key = normalizeScriptName(declared[i]?.name || declared[i]?.key || ""); + if (key === wantedKey) { + declared.splice(i, 1); + removedDeclared += 1; + } + } + + const removedRuntime = removeScriptRuntime(runtime, entry, wantedKey); + refreshUnsupportedScriptStats(runtime); + + if (removedDeclared === 0 && removedRuntime === 0) { + throw createViewerError("script_not_found", "Requested script was not found on the node", { + node: buildNodeSelectorPayload(entry), + script: scriptNameOrKey, + }); + } + + return removedDeclared + removedRuntime; +} + function makeRuntimeEntry(node, obj) { return { id: String(node?.id || ""), @@ -2344,11 +2691,17 @@ function buildScriptStates(entry) { const name = String(scriptCfg?.name || ""); const key = normalizeScriptName(name); const runtimeState = runtimeByKey.get(key); + const descriptor = getWebSupportedScriptDescriptor(name || key); out.push({ name, key, enabled: runtimeState ? !!runtimeState.enabled : toBoolean(scriptCfg?.enabled, true), supported: !!runtimeState, + webSupported: !!descriptor, + params: cloneJsonSafe(scriptCfg?.params ?? runtimeState?.params ?? {}, {}), + file: String(scriptCfg?.file || ""), + projectRelativePath: String(scriptCfg?.project_relative_path || ""), + scriptGuid: String(scriptCfg?.script_guid || ""), }); if (runtimeState) { runtimeByKey.delete(key); @@ -2356,11 +2709,19 @@ function buildScriptStates(entry) { } for (const runtimeState of runtimeByKey.values()) { + const runtimeName = String(runtimeState?.name || ""); + const runtimeKey = normalizeScriptName(runtimeName || runtimeState?.key || ""); + const descriptor = getWebSupportedScriptDescriptor(runtimeName || runtimeKey); out.push({ - name: String(runtimeState?.name || ""), - key: normalizeScriptName(runtimeState?.name || runtimeState?.key || ""), + name: runtimeName, + key: runtimeKey, enabled: !!runtimeState.enabled, supported: true, + webSupported: !!descriptor, + params: cloneJsonSafe(runtimeState?.params ?? {}, {}), + file: "", + projectRelativePath: "", + scriptGuid: "", }); } @@ -2410,6 +2771,7 @@ function buildSceneInfo(runtime) { environment: cloneJsonSafe(runtime?.manifest?.environment, {}), nodeCount: runtime?.nodeOrder?.length || 0, unsupportedScriptCount: runtime?.unsupportedScriptCount || 0, + webSupportedScripts: listWebSupportedScripts(), nodes: (runtime?.nodeOrder || []).map((entry) => buildNodeSummary(entry)), }; } @@ -2692,6 +3054,39 @@ function createViewerApi(runtime, THREE) { }); }, }, + scripts: { + listSupported() { + return runApiAction(runtime, "scripts.listSupported", () => { + return listWebSupportedScripts(); + }); + }, + attach(selector, script) { + return runApiAction(runtime, "scripts.attach", () => { + const entry = resolveRuntimeEntry(runtime, selector); + attachScriptToEntry(THREE, runtime, entry, script); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "script", + state: result, + }); + return result; + }); + }, + detach(selector, scriptNameOrKey) { + return runApiAction(runtime, "scripts.detach", () => { + const entry = resolveRuntimeEntry(runtime, selector); + detachScriptFromEntry(runtime, entry, scriptNameOrKey); + const result = buildNodeState(THREE, entry); + emitRuntimeEvent(runtime, "stateChanged", { + node: buildNodeSelectorPayload(entry), + changeType: "script", + state: result, + }); + return result; + }); + }, + }, events: { on(eventName, handler) { return runtime.eventHub.on(eventName, handler); @@ -2720,6 +3115,9 @@ function createMessageBridge(runtime, api) { ["animation.stop", (payload) => api.animation.stop(payload?.selector, payload?.options ?? {})], ["animation.seek", (payload) => api.animation.seek(payload?.selector, payload?.time)], ["animation.setSpeed", (payload) => api.animation.setSpeed(payload?.selector, payload?.speed)], + ["scripts.listSupported", () => api.scripts.listSupported()], + ["scripts.attach", (payload) => api.scripts.attach(payload?.selector, payload?.script ?? payload)], + ["scripts.detach", (payload) => api.scripts.detach(payload?.selector, payload?.name ?? payload?.key ?? payload?.script ?? "")], ]); let trustedOrigin = ""; @@ -2974,9 +3372,12 @@ async function bootstrap() { nodesById: new Map(), nodesByPublicId: new Map(), nodesByName: new Map(), + nodeMap: new Map(), + nodeNameLookup: new Map(), eventHub: createEventHub(), readyDeferred: makeDeferred(), animationControllers: [], + scriptRuntimes: [], unsupportedScriptCount: 0, isReady: false, bridge: null, @@ -3088,6 +3489,7 @@ async function bootstrap() { nodeMap.set(String(node.id || ""), obj); const entry = makeRuntimeEntry(node, obj); registerRuntimeEntry(runtime, entry); + runtime.nodeMap.set(String(node.id || ""), obj); if (node.kind === "model") { const modelUri = node.model?.uri; @@ -3166,8 +3568,10 @@ async function bootstrap() { const composer = bloomSetup.composer; runtime.composer = composer; - const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, nodeMap); + runtime.nodeNameLookup = buildNodeNameLookup(runtime.nodeMap); + const scriptRuntime = buildScriptRuntimeStates(THREE, runtime.nodeOrder, runtime.nodeMap, runtime.nodeNameLookup); const scriptStates = scriptRuntime.runtimes; + runtime.scriptRuntimes = scriptStates; runtime.unsupportedScriptCount = scriptRuntime.unsupportedCount; const resize = () => { @@ -3207,7 +3611,7 @@ async function bootstrap() { } } scene.updateMatrixWorld(true); - for (const state of scriptStates) { + for (const state of runtime.scriptRuntimes) { try { state.update(dt); } catch (err) { diff --git a/ui/panels/app_actions.py b/ui/panels/app_actions.py index aab4a09f..d8901259 100644 --- a/ui/panels/app_actions.py +++ b/ui/panels/app_actions.py @@ -205,7 +205,7 @@ class AppActions: def _mount_script_to_selected(self, script_name): """挂载脚本到选中对象""" - selected_node = self._get_selection_node() + selected_node = self._get_script_target_node() if hasattr(self, "_get_script_target_node") else self._get_selection_node() if not selected_node or selected_node.isEmpty(): self.add_error_message("请先选择一个对象") @@ -213,9 +213,22 @@ class AppActions: try: if hasattr(self, 'script_manager') and self.script_manager: + # 挂载前尝试重载该脚本,确保使用磁盘上的最新实现。 + try: + self.script_manager.reload_script(script_name) + except Exception: + pass script_component = self.script_manager.add_script_to_object(selected_node, script_name) if script_component: + script_info = {} + try: + script_info = self.script_manager.get_script_info(script_name) or {} + except Exception: + script_info = {} + script_file = str(script_info.get("file", "") or "").strip() self.add_success_message(f"脚本 {script_name} 已挂载到 {selected_node.getName()}") + if script_file: + self.add_info_message(f"脚本文件: {script_file}") print(f"[脚本系统] 挂载脚本: {script_name} -> {selected_node.getName()}") else: self.add_error_message(f"挂载脚本 {script_name} 失败") @@ -227,7 +240,7 @@ class AppActions: def _unmount_script_from_selected(self, script_name): """从选中对象卸载脚本""" - selected_node = self._get_selection_node() + selected_node = self._get_script_target_node() if hasattr(self, "_get_script_target_node") else self._get_selection_node() if not selected_node or selected_node.isEmpty(): self.add_error_message("请先选择一个对象") diff --git a/ui/panels/panel_delegates.py b/ui/panels/panel_delegates.py index 8e6d7461..a6b4903e 100644 --- a/ui/panels/panel_delegates.py +++ b/ui/panels/panel_delegates.py @@ -72,6 +72,49 @@ class PanelDelegates: return runtime_node return self._get_selection_source_node() + def _get_script_target_node(self): + """ + Return the runtime-visible node that scripts should mount to. + + In SSBO mode, mounting to source tree nodes (e.g. ssbo_source_scene_root) + won't affect the visible runtime scene. Prefer dynamic runtime object/proxy/model. + """ + ssbo_editor = getattr(self, "ssbo_editor", None) + if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection(): + controller = getattr(ssbo_editor, "controller", None) + selected_ids = list(getattr(ssbo_editor, "selected_ids", []) or []) + + # Single selection: prefer the exact dynamic runtime object. + if controller and len(selected_ids) == 1: + try: + obj_np = (getattr(controller, "id_to_object_np", {}) or {}).get(selected_ids[0]) + except Exception: + obj_np = None + if obj_np and self._node_is_valid(obj_np, require_attached=False): + return obj_np + + # Multi-selection proxy (if exists) should drive runtime edits. + group_proxy = getattr(ssbo_editor, "_group_proxy", None) + if group_proxy and self._node_is_valid(group_proxy, require_attached=False): + return group_proxy + + # Fallback scene node, but skip source-tree nodes. + scene_node = getattr(ssbo_editor, "get_selection_scene_node", lambda: None)() + if scene_node and self._node_is_valid(scene_node, require_attached=False): + try: + is_source = bool(getattr(ssbo_editor, "is_source_tree_node", lambda *_: False)(scene_node)) + except Exception: + is_source = False + if not is_source: + return scene_node + + # Last fallback: runtime root model (still visible), not source root. + runtime_model = getattr(ssbo_editor, "model", None) + if runtime_model and self._node_is_valid(runtime_model, require_attached=False): + return runtime_model + + return self._get_selection_node() + def _get_selection_key(self): ssbo_editor = getattr(self, "ssbo_editor", None) if ssbo_editor and hasattr(ssbo_editor, "has_active_selection") and ssbo_editor.has_active_selection(): diff --git a/ui/panels/script_panels.py b/ui/panels/script_panels.py index 755513e7..2f0da99b 100644 --- a/ui/panels/script_panels.py +++ b/ui/panels/script_panels.py @@ -260,8 +260,10 @@ class ScriptPanels: def _draw_script_mounting_group(self): """绘制脚本挂载组""" if imgui.collapsing_header("脚本挂载"): - # 显示当前选中对象 - selected_node = self._get_selection_source_node() + # 与挂载/卸载操作保持一致:统一使用脚本实际挂载目标节点。 + selected_node = self._get_script_target_node() if hasattr(self, "_get_script_target_node") else self._get_selection_node() + if (not selected_node) or selected_node.isEmpty(): + selected_node = self._get_selection_source_node() if selected_node and not selected_node.isEmpty(): imgui.text("选中对象:")