From 45e4aedabb3fa5fb854dd26dec0c2ba17da79f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E9=A3=8E=E9=80=8D=E9=81=A5=E6=B8=B8?= Date: Sun, 13 Sep 2026 14:48:16 +0800 Subject: [PATCH] feat(editor): author montage layer bone masks --- docs/usr/modules/editor.md | 3 ++ examples/combat-action-editor/main.nut | 27 +++++++++++++++++- .../editor/ActionTimelineScriptBindings.cpp | 28 +++++++++++++++++++ test/action_montage.cpp | 4 +++ test/editor_action_timeline_script.cpp | 7 +++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/docs/usr/modules/editor.md b/docs/usr/modules/editor.md index e7d69d24..d0483d4c 100644 --- a/docs/usr/modules/editor.md +++ b/docs/usr/modules/editor.md @@ -683,6 +683,9 @@ Montage 运行时副本;Undo/Redo、`jumpRuntimeSection`、`evaluateRuntimeSec `syncRuntimeSection`、`syncRuntimeSectionAndJump`、`clearRuntimeSectionSync` 和磁吸目标因此共享一份边界事实。 归一化求值把 `[0,1]` 映射到物理 Section;同步倍率可以只配置,也可以配置后立即跳到该段起点,并可明确清除。 运行时按资产的 `animationLayer` 选择协调器层,视口读取该层的双槽归一化合成姿态。 +`setRuntimeBoneMask` 会同步复制一个与预览骨架匹配的 `AnimBoneMask` 到当前层,调用方随后可以安全销毁或修改 +原 Mask;`clearRuntimeBoneMask` 恢复全骨骼影响。示例的 Montage Inspector 可从当前选中关节生成 Mask,选择是否 +包含子关节,并提供紧凑的 Apply/Clear 工具栏。 运行中的预览还可用 `setRuntimeRate/getRuntimeRate` 设置和读取严格为正的倍率;倍率只缩放调用者注入的 delta,不读取墙钟,也不写回资产的 `basePlayRate`。`getRuntimePhysicalSection`、 `getRuntimeSectionProgress` 和 `getRuntimeWeight` 可直接驱动运行诊断 HUD。 diff --git a/examples/combat-action-editor/main.nut b/examples/combat-action-editor/main.nut index d722a286..774c254c 100644 --- a/examples/combat-action-editor/main.nut +++ b/examples/combat-action-editor/main.nut @@ -643,6 +643,13 @@ function panelInspector() { ui.checkbox("Horizontal", true, "montage-root-horizontal"); ui.checkbox("Vertical", true, "montage-root-vertical"); ui.checkbox("Rotation", true, "montage-root-rotation"); + ui.text("Layer Bone Mask", "montage-mask-title"); + ui.slider("Selected Joint", 1.0, 0.0, 1.0, "montage-mask-weight"); + ui.checkbox("Include Children", true, "montage-mask-children"); + ui.beginToolbar("montage-mask-tools"); + ui.iconButton("save", "Apply", "apply-montage-mask"); + ui.iconButton("close", "Clear", "clear-montage-mask"); + ui.end(); ui.text("Physical Sections", "physical-sections-title"); ui.combo("Split", sectionSplitChoices(), combatEditor.selectedSplit, "section-split-selector"); ui.slider("Split Time", 0.5, 0.0, combatEditor.timeline.getDuration(), "section-split-time"); @@ -1090,6 +1097,23 @@ function handleUiEvents() { applyHistory(id == "track-undo" ? "undo" : "redo"); } else if (id == "montage-undo" || id == "montage-redo") { applyHistory(id == "montage-undo" ? "undo" : "redo"); + } else if (id == "apply-montage-mask") { + ui.select("action.inspector"); + local mask = anim.newBoneMask(combatEditor.skeleton); + mask.setAll(0.0); + local bone = combatEditor.clipEditor.getSelectedBone(); + local applied = ui.getChecked("montage-mask-children") ? + mask.setBoneAndChildren(bone, ui.getValue("montage-mask-weight")) : + mask.setBoneWeightByName(bone, ui.getValue("montage-mask-weight")); + if (!applied) combatEditor.status = "Bone mask joint was not found · " + bone; + else { + local result = combatEditor.timeline.setRuntimeBoneMask(mask); + combatEditor.status = result.ok ? "Layer mask applied from joint · " + bone + : result.status.summary; + } + } else if (id == "clear-montage-mask") { + local result = combatEditor.timeline.clearRuntimeBoneMask(); + combatEditor.status = result.ok ? "Layer bone mask cleared" : result.status.summary; } else if (id == "add-section-split") { local splitTime = combatEditor.timeline.getPreviewTime(); local result = combatEditor.timeline.addSectionSplit(splitTime); @@ -1179,7 +1203,8 @@ function handleUiEvents() { result = combatEditor.timeline.setSectionSplit(combatEditor.selectedSplit, splitTime); if (result.ok) combatEditor.selectedSplit = sectionSplitIndexAt(splitTime); combatEditor.status = result.ok ? "Physical section split moved" : result.status.summary; - } else if (id.find("montage-") == 0 && id != "montage-tools") { + } else if (id.find("montage-") == 0 && id != "montage-tools" && + id.find("montage-mask-") != 0) { result = commitMontageSettings(); combatEditor.status = result.ok ? "Montage settings committed" : result.status.summary; } else if (id == "insert-track") combatEditor.insertTrack = ui.getValue("insert-track").tointeger(); diff --git a/src/modules/action/editor/ActionTimelineScriptBindings.cpp b/src/modules/action/editor/ActionTimelineScriptBindings.cpp index 44e7f357..ca272bd3 100644 --- a/src/modules/action/editor/ActionTimelineScriptBindings.cpp +++ b/src/modules/action/editor/ActionTimelineScriptBindings.cpp @@ -10,6 +10,7 @@ #include "action/editor/ActionTimelineWidget.h" #include "animation/AnimClip.h" #include "animation/AnimImporter.h" +#include "animation/AnimLayerMixer.h" #include "animation/AnimPose.h" #include "animation/MontageCoordinator.h" #include "animation/MontagePlayer.h" @@ -713,6 +714,20 @@ public: : StatusCode::NoOp)); } + [[nodiscard]] Result setRuntimeBoneMask(const animation::AnimBoneMask& mask) { + if (!coordinator_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return coordinator_->setLayerBoneMask(activeLayer_, mask); + } + + [[nodiscard]] Result clearRuntimeBoneMask() { + if (!coordinator_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return coordinator_->clearLayerBoneMask(activeLayer_); + } + [[nodiscard]] Result beginRuntimeBlendOut(Duration duration) { auto* montage = activeMontage(); if (!montage) @@ -1459,6 +1474,19 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return script::projectResult(vm, self->clearRuntimeSectionSync(static_cast(sectionIndex))); }); + actionEditor.addFunc("setRuntimeBoneMask", + [vm](ScriptActionTimelineEditor* self, animation::AnimBoneMask* mask) { + if (!self || !mask) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor and bone mask must not be null", "mask"); + return script::projectResult(vm, self->setRuntimeBoneMask(*mask)); + }); + actionEditor.addFunc("clearRuntimeBoneMask", [vm](ScriptActionTimelineEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor must not be null"); + return script::projectResult(vm, self->clearRuntimeBoneMask()); + }); actionEditor.addFunc("beginRuntimeBlendOut", [vm](ScriptActionTimelineEditor* self, float durationSeconds) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); diff --git a/test/action_montage.cpp b/test/action_montage.cpp index 319a69ab..969711f0 100644 --- a/test/action_montage.cpp +++ b/test/action_montage.cpp @@ -430,6 +430,10 @@ TEST_CASE("actionMontage.coordinatorLayerMaskRestrictsCompositionPerBone") { REQUIRE(configured.ok()); REQUIRE_EQ(configured.value().size(), 2U); CHECK_EQ(configured.value()[1], 0.5f); + upperBody.setAll(0.0f); + configured = coordinator.layerBoneMask(0); + REQUIRE(configured.ok()); + CHECK_EQ(configured.value()[1], 0.5f); auto handle = coordinator.play(0, montageTimeline(), maskedMontageClips(), eve::action::ActionExecutionId(21), eve::SimulationTick(1)); diff --git a/test/editor_action_timeline_script.cpp b/test/editor_action_timeline_script.cpp index 76f51aab..18b9046a 100644 --- a/test/editor_action_timeline_script.cpp +++ b/test/editor_action_timeline_script.cpp @@ -41,6 +41,11 @@ TEST_CASE("editor.actionTimeline.scriptUsesCanonicalTransactionsAndWorkspace") { sectionProgressBeforeStart <- actionEditor.evaluateRuntimeSectionProgress(0, 0.5); sectionSyncJumpBeforeStart <- actionEditor.syncRuntimeSectionAndJump(0, 0.25); clearSectionSyncBeforeStart <- actionEditor.clearRuntimeSectionSync(0); + testAnimation <- eve.Animation(); + testSkeleton <- testAnimation.newSkeleton(); + testBoneMask <- testAnimation.newBoneMask(testSkeleton); + setBoneMaskBeforeStart <- actionEditor.setRuntimeBoneMask(testBoneMask); + clearBoneMaskBeforeStart <- actionEditor.clearRuntimeBoneMask(); configured <- actionEditor.configureWorkspace(workspace); workspacePanelCount <- workspace.getPanelCount(); viewport <- actionEditor.setViewport(800.0, 36.0, 120.0); @@ -210,6 +215,8 @@ TEST_CASE("editor.actionTimeline.scriptUsesCanonicalTransactionsAndWorkspace") { CHECK(!vm.find("sectionProgressBeforeStart").toTable().get("ok")); CHECK(!vm.find("sectionSyncJumpBeforeStart").toTable().get("ok")); CHECK(!vm.find("clearSectionSyncBeforeStart").toTable().get("ok")); + CHECK(!vm.find("setBoneMaskBeforeStart").toTable().get("ok")); + CHECK(!vm.find("clearBoneMaskBeforeStart").toTable().get("ok")); CHECK(vm.find("configured").toTable().get("ok")); CHECK_EQ(vm.find("workspacePanelCount").toInt(), 4); CHECK(vm.find("viewport").toTable().get("ok"));