From 681d13ea48aa093340ddedaf0990274b125b5a43 Mon Sep 17 00:00:00 2001 From: Rowland <975945824@qq.com> Date: Tue, 17 Mar 2026 09:24:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=93=E5=8C=85=E5=8A=A8?= =?UTF-8?q?=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/webgl_packager.py | 74 +++++++++++++++++++++ scene/scene_manager_io_mixin.py | 30 +++++++++ templates/webgl/viewer.js | 113 +++++++++++++++++++++++++++++++- ui/panels/animation_tools.py | 26 ++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) diff --git a/project/webgl_packager.py b/project/webgl_packager.py index 4a9dd19b..df642c40 100644 --- a/project/webgl_packager.py +++ b/project/webgl_packager.py @@ -509,12 +509,64 @@ class WebGLPackager: "material_override": material_override, "source_model_tag": source_tag, } + animation = self._extract_animation_settings(node, model_source) + if animation: + entry["animation"] = animation if textures: entry["texture_overrides"] = textures if subnode_overrides: entry["subnode_overrides"] = subnode_overrides return entry + def _extract_animation_settings(self, node, model_source: str) -> Dict[str, Any]: + has_animation_hint = False + try: + if node.hasTag("has_animations"): + has_animation_hint = (node.getTag("has_animations").strip().lower() == "true") + except Exception: + has_animation_hint = False + + python_animation_flag = self._safe_get_python_tag(node, "animation") + if python_animation_flag is True: + has_animation_hint = True + + clip_name = ( + self._safe_get_python_tag(node, "selected_animation") + or self._safe_get_tag_value(node, "saved_selected_animation") + or self._safe_get_tag_value(node, "selected_animation") + ) + clip_name = str(clip_name).strip() if clip_name is not None else "" + + speed_value = ( + self._safe_get_python_tag(node, "anim_speed") + or self._safe_get_tag_value(node, "saved_anim_speed") + or self._safe_get_tag_value(node, "anim_speed") + ) + speed = self._safe_float(speed_value, 1.0) + if abs(speed) < 1e-4: + speed = 1.0 + + mode = ( + self._safe_get_python_tag(node, "anim_play_mode") + or self._safe_get_tag_value(node, "saved_anim_play_mode") + or self._safe_get_tag_value(node, "anim_play_mode") + or "" + ) + mode = str(mode).strip().lower() + if mode not in {"play", "loop", "pause", "stop"}: + mode = "loop" + + if not (has_animation_hint or clip_name): + return {} + + return { + "enabled": True, + "clip_name": clip_name, + "speed": speed, + "mode": mode, + "autoplay": mode != "stop", + } + def _collect_ssbo_subnode_transform_overrides(self, model_node) -> Dict[str, Any]: """ Collect changed subnode transforms from SSBO controller runtime state. @@ -1458,6 +1510,28 @@ class WebGLPackager: except Exception: return float(default) + @staticmethod + def _safe_get_python_tag(node, tag_name: str) -> Any: + try: + if hasattr(node, "hasPythonTag") and node.hasPythonTag(tag_name): + return node.getPythonTag(tag_name) + except Exception: + pass + try: + value = node.getPythonTag(tag_name) + return value + except Exception: + return None + + @staticmethod + def _safe_get_tag_value(node, tag_name: str) -> str: + try: + if node.hasTag(tag_name): + return (node.getTag(tag_name) or "").strip() + except Exception: + return "" + return "" + def _unique_filename(self, stem: str, suffix: str) -> str: safe_stem = self._sanitize_filename(stem) or "asset" key = f"{safe_stem}{suffix.lower()}" diff --git a/scene/scene_manager_io_mixin.py b/scene/scene_manager_io_mixin.py index b91539b9..8bf09457 100644 --- a/scene/scene_manager_io_mixin.py +++ b/scene/scene_manager_io_mixin.py @@ -889,6 +889,21 @@ class SceneManagerIOMixin: node.setTag("saved_model_path", node.getTag("model_path")) if node.hasTag("can_create_actor_from_memory"): node.setTag("saved_can_create_actor_from_memory", node.getTag("can_create_actor_from_memory")) + try: + if hasattr(node, "hasPythonTag") and node.hasPythonTag("selected_animation"): + selected_anim = node.getPythonTag("selected_animation") + if selected_anim: + node.setTag("saved_selected_animation", str(selected_anim)) + if hasattr(node, "hasPythonTag") and node.hasPythonTag("anim_speed"): + anim_speed = node.getPythonTag("anim_speed") + if anim_speed is not None: + node.setTag("saved_anim_speed", str(float(anim_speed))) + if hasattr(node, "hasPythonTag") and node.hasPythonTag("anim_play_mode"): + anim_mode = node.getPythonTag("anim_play_mode") + if anim_mode: + node.setTag("saved_anim_play_mode", str(anim_mode).lower()) + except Exception: + pass # 保存 SSBO 子物体变换(仅当前受 SSBOEditor 控制的模型根)。 ssbo_subobject_payload = self._serialize_ssbo_subobject_transforms(node) @@ -1537,6 +1552,21 @@ class SceneManagerIOMixin: self._fixModelStructure(nodePath) self._restoreModelAnimationInfo(nodePath) self._processModelAnimations(nodePath) + try: + if nodePath.hasTag("saved_selected_animation"): + selected_anim = nodePath.getTag("saved_selected_animation").strip() + if selected_anim: + nodePath.setPythonTag("selected_animation", selected_anim) + if nodePath.hasTag("saved_anim_speed"): + speed_text = nodePath.getTag("saved_anim_speed").strip() + if speed_text: + nodePath.setPythonTag("anim_speed", float(speed_text)) + if nodePath.hasTag("saved_anim_play_mode"): + mode_text = nodePath.getTag("saved_anim_play_mode").strip().lower() + if mode_text: + nodePath.setPythonTag("anim_play_mode", mode_text) + except Exception: + pass try: ssbo_editor = getattr(self.world, "ssbo_editor", None) repair_fn = getattr(ssbo_editor, "_repair_missing_textures", None) if ssbo_editor else None diff --git a/templates/webgl/viewer.js b/templates/webgl/viewer.js index 661415df..7dcdf211 100644 --- a/templates/webgl/viewer.js +++ b/templates/webgl/viewer.js @@ -971,6 +971,90 @@ function directionToThree(THREE, direction, basis) { return d.normalize(); } +function normalizeAnimationName(name) { + return canonicalizeNameSegment(String(name ?? "")); +} + +function pickAnimationClip(clips, requestedName) { + if (!Array.isArray(clips) || clips.length === 0) return null; + const requested = String(requestedName ?? "").trim(); + if (!requested) return clips[0]; + + let clip = clips.find((c) => String(c?.name ?? "") === requested); + if (clip) return clip; + + const reqLower = requested.toLowerCase(); + clip = clips.find((c) => String(c?.name ?? "").toLowerCase() === reqLower); + if (clip) return clip; + + const reqNorm = normalizeAnimationName(requested); + if (reqNorm) { + clip = clips.find((c) => normalizeAnimationName(c?.name ?? "") === reqNorm); + if (clip) return clip; + + clip = clips.find((c) => { + const n = normalizeAnimationName(c?.name ?? ""); + return n && (n.includes(reqNorm) || reqNorm.includes(n)); + }); + if (clip) return clip; + } + + return clips[0]; +} + +function setupModelAnimation(THREE, root, gltf, animationConfig, nodeName) { + const config = animationConfig && typeof animationConfig === "object" ? animationConfig : {}; + if (config.enabled === false) { + return { mixer: null, clipName: "", mode: "stop", started: false }; + } + + const clips = Array.isArray(gltf?.animations) ? gltf.animations : []; + if (clips.length === 0) { + if (config.clip_name || config.mode) { + console.warn("[WebGLPack] Animation requested but no clips found:", nodeName || "(unnamed)"); + } + return { mixer: null, clipName: "", mode: "stop", started: false }; + } + + const mode = String(config.mode || "loop").toLowerCase(); + const clip = pickAnimationClip(clips, config.clip_name); + if (!clip) { + return { mixer: null, clipName: "", mode, started: false }; + } + + const mixer = new THREE.AnimationMixer(root); + const action = mixer.clipAction(clip); + const speedRaw = Number(config.speed ?? 1.0); + const speed = Number.isFinite(speedRaw) && Math.abs(speedRaw) > 1e-4 ? speedRaw : 1.0; + + action.enabled = true; + action.clampWhenFinished = (mode === "play"); + if (mode === "play") { + action.setLoop(THREE.LoopOnce, 1); + } else { + action.setLoop(THREE.LoopRepeat, Infinity); + } + action.setEffectiveTimeScale(speed); + + const shouldPlay = Boolean(config.autoplay ?? (mode !== "stop")); + const startTimeRaw = Number(config.start_time ?? 0); + const duration = Math.max(0.0001, Number(clip.duration || 0.0001)); + const startTime = Number.isFinite(startTimeRaw) ? (startTimeRaw % duration) : 0; + + action.play(); + action.time = startTime; + if (!shouldPlay || mode === "stop" || mode === "pause") { + action.paused = true; + } + + return { + mixer, + clipName: String(clip.name || ""), + mode, + started: shouldPlay && mode !== "stop" && mode !== "pause", + }; +} + async function bootstrap() { setStatus("Loading WebGL dependencies..."); @@ -1082,6 +1166,8 @@ async function bootstrap() { const nodeMap = new Map(); const pendingModelLoads = []; + const animationMixers = []; + const animationStates = []; const gltfLoader = new GLTFLoader(); const textureLoader = new THREE.TextureLoader(); @@ -1146,6 +1232,24 @@ async function bootstrap() { ); applyMaterialOverride(THREE, root, node.material_override || null); applyTextureOverrides(THREE, root, node.texture_overrides || [], textureLoader); + const anim = setupModelAnimation( + THREE, + root, + gltf, + node.animation || null, + node.name || node.id || "", + ); + if (anim.mixer) { + animationMixers.push(anim.mixer); + } + if (anim.clipName) { + animationStates.push({ + node: node.name || node.id || "node", + clip: anim.clipName, + mode: anim.mode, + started: anim.started, + }); + } obj.add(root); } resolve(); @@ -1203,7 +1307,7 @@ async function bootstrap() { resize(); setStatus( - `Scene ready. Nodes: ${(data.nodes || []).length}.\nUse mouse to orbit, wheel to zoom.`, + `Scene ready. Nodes: ${(data.nodes || []).length}, Animations: ${animationStates.length}.\nUse mouse to orbit, wheel to zoom.`, "ok", ); @@ -1212,6 +1316,13 @@ async function bootstrap() { requestAnimationFrame(tick); const dt = clock.getDelta(); if (dt >= 0) { + for (const mixer of animationMixers) { + try { + mixer.update(dt); + } catch (err) { + // Keep render loop alive even if one mixer fails. + } + } controls.update(); renderer.render(scene, camera); } diff --git a/ui/panels/animation_tools.py b/ui/panels/animation_tools.py index 764826e1..004db6c8 100644 --- a/ui/panels/animation_tools.py +++ b/ui/panels/animation_tools.py @@ -1310,13 +1310,33 @@ class AnimationTools: try: origin_model.setPythonTag("selected_animation", current_anim) + origin_model.setTag("saved_selected_animation", str(current_anim)) if owner_model and owner_model != origin_model: owner_model.setPythonTag("selected_animation", current_anim) + owner_model.setTag("saved_selected_animation", str(current_anim)) except Exception: pass return current_anim + def _save_animation_runtime_state(self, origin_model, owner_model=None, mode=None, speed=None): + targets = [origin_model] + if owner_model and owner_model != origin_model: + targets.append(owner_model) + + for target in targets: + try: + if mode is not None: + mode_text = str(mode).strip().lower() + target.setPythonTag("anim_play_mode", mode_text) + target.setTag("saved_anim_play_mode", mode_text) + if speed is not None: + speed_value = float(speed) + target.setPythonTag("anim_speed", speed_value) + target.setTag("saved_anim_speed", str(speed_value)) + except Exception: + continue + def _should_swap_visibility_for_actor(self, owner_model, actor): """ 是否需要“隐藏原模型/显示Actor”的切换。 @@ -1415,6 +1435,7 @@ class AnimationTools: except Exception: pass actor.play(current_anim) + self._save_animation_runtime_state(origin_model, owner_model, mode="play") print(f"『动画播放』:{current_anim}") def _pauseAnimation(self, origin_model): @@ -1455,6 +1476,7 @@ class AnimationTools: # 停止动画(保持当前姿势) actor.stop() + self._save_animation_runtime_state(origin_model, owner_model, mode="pause") print("『动画』暂停") def _stopAnimation(self, origin_model): @@ -1485,6 +1507,7 @@ class AnimationTools: # 移除位置维护任务 taskMgr.remove(f"maintain_anim_pos_{id(actor)}") + self._save_animation_runtime_state(origin_model, owner_model, mode="stop") print("『动画』停止切换至原始模型") @@ -1544,6 +1567,7 @@ class AnimationTools: except Exception: pass actor.loop(current_anim) + self._save_animation_runtime_state(origin_model, owner_model, mode="loop") print(f"[动画] 循环: {current_anim}") def _setAnimationSpeed(self, origin_model, speed): @@ -1557,12 +1581,14 @@ class AnimationTools: current_anim = self._resolve_selected_animation_name(origin_model, actor, owner_model) if current_anim: actor.setPlayRate(speed, current_anim) + self._save_animation_runtime_state(origin_model, owner_model, speed=speed) print(f"[动画] 速度设为: {speed} ({current_anim})") else: # 兜底:尝试所有动画 anim_names = actor.getAnimNames() for anim_name in anim_names: actor.setPlayRate(speed, anim_name) + self._save_animation_runtime_state(origin_model, owner_model, speed=speed) print(f"[动画] 速度设为: {speed} (所有动画)")