diff --git a/qml/AttachBoneDialog.qml b/qml/AttachBoneDialog.qml new file mode 100644 index 000000000..a2d25732b --- /dev/null +++ b/qml/AttachBoneDialog.qml @@ -0,0 +1,109 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import PropertiesPanel 1.0 +import MaterialEditorQML 1.0 + +Window { + id: dialog + title: "Attach Bone to Entity" + width: 400 + height: 200 + minimumWidth: 340 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property string boneName: "" + property var targetEntities: [] + + signal attachRequested(string dstEntityName) + signal cancelled() + + function openForBone(name, targets) { + dialog.boneName = name || "" + dialog.targetEntities = targets || [] + var names = [] + for (var i = 0; i < dialog.targetEntities.length; i++) + names.push(dialog.targetEntities[i].name) + targetCombo.model = names + targetCombo.currentIndex = names.length > 0 ? 0 : -1 + dialog.show() + dialog.raise() + dialog.requestActivate() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: 12 + color: PropertiesPanelController.textColor + text: dialog.boneName.length > 0 + ? "Copy \"" + dialog.boneName + "\" (and descendants) onto another entity's skeleton (rig only):" + : "Attach the selected bone onto another entity's skeleton:" + } + + ThemedComboBox { + id: targetCombo + Layout.fillWidth: true + Layout.preferredHeight: 24 + font.pixelSize: 11 + displayText: currentIndex >= 0 ? currentText : "(no other entities)" + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: 8 + Rectangle { + width: cancelLabel.implicitWidth + 20 + height: 28 + radius: 3 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + Text { + id: cancelLabel + anchors.centerIn: parent + text: "Cancel" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { dialog.cancelled(); dialog.close() } + } + } + Rectangle { + width: okLabel.implicitWidth + 20 + height: 28 + radius: 3 + opacity: targetCombo.currentIndex >= 0 ? 1.0 : 0.45 + color: PropertiesPanelController.highlightColor + Text { + id: okLabel + anchors.centerIn: parent + text: "Attach" + color: "white" + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + enabled: targetCombo.currentIndex >= 0 + cursorShape: Qt.PointingHandCursor + onClicked: { + dialog.attachRequested(targetCombo.currentText) + dialog.close() + } + } + } + } + } +} diff --git a/qml/BoneContextMenu.qml b/qml/BoneContextMenu.qml new file mode 100644 index 000000000..d7ee91db0 --- /dev/null +++ b/qml/BoneContextMenu.qml @@ -0,0 +1,166 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Window +import PropertiesPanel 1.0 +import AnimationControl 1.0 + +// Floating inspector-styled context menu for bone hierarchy ops. +// Opened from the Skeleton Tools bone picker or a viewport bone right-click. +Window { + id: menu + flags: Qt.Popup | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint + color: "transparent" + width: menuFrame.implicitWidth + height: menuFrame.implicitHeight + + property bool closeOnDeactivate: false + property bool boneConnectedSnapshot: false + + signal setParentRequested() + signal detachRequested() + signal splitRequested() + signal connectToggleRequested() + signal attachRequested() + signal duplicateRequested() + signal renameRequested() + signal removeRequested() + + Timer { + id: armCloseTimer + interval: 200 + onTriggered: menu.closeOnDeactivate = true + } + + function openAt(globalX, globalY) { + menu.closeOnDeactivate = false + menu.boneConnectedSnapshot = SkeletonEditor.isSelectedBoneConnected() + menu.x = globalX + menu.y = globalY + menu.show() + menu.raise() + menu.requestActivate() + armCloseTimer.restart() + } + + function closeMenu() { + armCloseTimer.stop() + menu.closeOnDeactivate = false + menu.close() + } + + readonly property bool hasBone: AnimationControlController.selectedBone.length > 0 + + Rectangle { + id: menuFrame + implicitWidth: Math.max(180, menuCol.implicitWidth + 8) + implicitHeight: menuCol.implicitHeight + 8 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 4 + + Column { + id: menuCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 4 + spacing: 1 + + component MenuRow: Rectangle { + id: row + property string label: "" + property bool enabled: true + property bool danger: false + signal activated() + + width: parent.width + height: 24 + radius: 3 + opacity: row.enabled ? 1.0 : 0.4 + color: rowMa.containsMouse && row.enabled + ? PropertiesPanelController.highlightColor + : "transparent" + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: row.label + color: rowMa.containsMouse && row.enabled + ? "white" + : (row.danger ? "#e07070" : PropertiesPanelController.textColor) + font.pixelSize: 11 + } + MouseArea { + id: rowMa + anchors.fill: parent + hoverEnabled: true + enabled: row.enabled + cursorShape: row.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + row.activated() + menu.closeMenu() + } + } + } + + MenuRow { + label: "Set parent…" + enabled: menu.hasBone + onActivated: menu.setParentRequested() + } + MenuRow { + label: "Detach" + enabled: menu.hasBone + onActivated: menu.detachRequested() + } + MenuRow { + label: "Split…" + enabled: menu.hasBone + onActivated: menu.splitRequested() + } + MenuRow { + label: menu.boneConnectedSnapshot ? "Disconnect" : "Connect" + enabled: menu.hasBone + onActivated: menu.connectToggleRequested() + } + MenuRow { + label: "Attach to entity…" + enabled: menu.hasBone + onActivated: menu.attachRequested() + } + + Rectangle { + width: parent.width + height: 1 + color: PropertiesPanelController.borderColor + opacity: 0.6 + } + + MenuRow { + label: "Duplicate" + enabled: menu.hasBone + onActivated: menu.duplicateRequested() + } + MenuRow { + label: "Rename…" + enabled: menu.hasBone + onActivated: menu.renameRequested() + } + MenuRow { + label: "Remove…" + enabled: menu.hasBone + danger: true + onActivated: menu.removeRequested() + } + } + } + + // Click outside closes — armed after a short delay so the viewport + // keeping focus on open doesn't dismiss the menu immediately. + onActiveChanged: { + if (!active && visible && menu.closeOnDeactivate) + Qt.callLater(menu.closeMenu) + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index dba40f00a..b93614223 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -78,6 +78,10 @@ Rectangle { property bool rigStatusError: false property string boneEditStatus: "" property bool boneEditError: false + property bool selectedBoneConnected: false + function refreshSelectedBoneConnected() { + root.selectedBoneConnected = SkeletonEditor.isSelectedBoneConnected() + } function runAutoRig() { if (AutoRigController.busy || !AutoRigController.hasRiggableSelection) return @@ -2746,6 +2750,7 @@ Rectangle { function onSelectionChanged() { skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() skeletonToolsCol.ensureBoneListBound() + root.refreshSelectedBoneConnected() } } Connections { @@ -2753,8 +2758,13 @@ Rectangle { function onSkeletonStructureChanged() { skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() skeletonToolsCol.ensureBoneListBound() + root.refreshSelectedBoneConnected() } } + Connections { + target: AnimationControlController + function onBoneListChanged() { root.refreshSelectedBoneConnected() } + } function ensureBoneListBound() { if (!PropertiesPanelController.hasSkeletonSelection) return @@ -2766,7 +2776,10 @@ Rectangle { AnimationControlController.bindSkeletonForEntity(ent) } - Component.onCompleted: skeletonToolsCol.ensureBoneListBound() + Component.onCompleted: { + skeletonToolsCol.ensureBoneListBound() + root.refreshSelectedBoneConnected() + } Text { width: parent.width - 16 @@ -2901,8 +2914,14 @@ Rectangle { id: skelBoneSelectorMouse anchors.fill: parent hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton enabled: SkeletonEditor.hasSkeletonSelection - onClicked: { + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + const g = skelBoneSelector.mapToGlobal(mouse.x, mouse.y) + root.openBoneContextMenu(g.x, g.y) + return + } skelBoneSelector.dropdownOpen = !skelBoneSelector.dropdownOpen if (skelBoneSelector.dropdownOpen) { skelBoneFilter.text = "" @@ -3025,71 +3044,81 @@ Rectangle { } } - Row { + // Bone action buttons — two rows so labels stay readable in the + // narrow Inspector dock. + Column { spacing: 4 width: skeletonToolsCol.width - 16 - Repeater { - model: [ - { label: "+ Bone", action: "create", needsBone: false }, - { label: "Duplicate", action: "duplicate", needsBone: true }, - { label: "Remove", action: "remove", needsBone: true }, - { label: "Rename", action: "rename", needsBone: true } - ] - delegate: Rectangle { - required property var modelData - width: Math.max(52, labelText.implicitWidth + 12) - height: 22 - radius: 3 - color: btnMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.2) - : PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - opacity: (SkeletonEditor.hasSkeletonSelection - && (!modelData.needsBone || AnimationControlController.selectedBone.length > 0)) - ? 1.0 : 0.45 + component SkelToolButton: Rectangle { + id: skelBtn + property string label: "" + property string action: "" + property bool needsBone: true + width: Math.max(56, skelBtnLabel.implicitWidth + 14) + height: 22 + radius: 3 + color: skelBtnMa.containsMouse || skelBtn.activeFocus + ? Qt.lighter(PropertiesPanelController.headerColor, 1.2) + : PropertiesPanelController.headerColor + border.color: skelBtn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: skelBtn.activeFocus ? 2 : 1 + opacity: (SkeletonEditor.hasSkeletonSelection + && (!skelBtn.needsBone || AnimationControlController.selectedBone.length > 0)) + ? 1.0 : 0.45 + activeFocusOnTab: enabled + Accessible.role: Accessible.Button + Accessible.name: skelBtnLabel.text + enabled: SkeletonEditor.hasSkeletonSelection + && (!skelBtn.needsBone || AnimationControlController.selectedBone.length > 0) - Text { - id: labelText - anchors.centerIn: parent - text: modelData.label - color: PropertiesPanelController.textColor - font.pixelSize: 10 + Text { + id: skelBtnLabel + anchors.centerIn: parent + text: { + if (skelBtn.action === "connect") + return root.selectedBoneConnected ? "Disconnect" : "Connect" + return skelBtn.label } - MouseArea { - id: btnMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - enabled: SkeletonEditor.hasSkeletonSelection - && (!modelData.needsBone || AnimationControlController.selectedBone.length > 0) - onClicked: { - root.boneEditError = false - root.boneEditStatus = "" - if (modelData.action === "create") { - if (SkeletonEditor.createBoneForSelected("")) { - root.boneEditStatus = "Bone created." - } else { - root.boneEditError = true - root.boneEditStatus = "Could not create bone." - } - } else if (modelData.action === "duplicate") { - if (SkeletonEditor.duplicateSelectedBone()) { - root.boneEditStatus = "Bone duplicated." - } else { - root.boneEditError = true - root.boneEditStatus = "Select a bone first." - } - } else if (modelData.action === "remove") { - root.openRemoveBoneDialog() - } else if (modelData.action === "rename") { - root.openRenameBoneDialog() - } - } + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + id: skelBtnMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: skelBtn.enabled + onClicked: { + skelBtn.forceActiveFocus() + root.runSkeletonToolAction(skelBtn.action) + } + } + Keys.onPressed: function(event) { + if (!skelBtn.enabled) return + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter + || event.key === Qt.Key_Space) { + root.runSkeletonToolAction(skelBtn.action) + event.accepted = true } } } + + Flow { + width: parent.width + spacing: 4 + SkelToolButton { label: "+ Bone"; action: "create"; needsBone: false } + SkelToolButton { label: "Duplicate"; action: "duplicate" } + SkelToolButton { label: "Reparent"; action: "reparent" } + SkelToolButton { label: "Detach"; action: "detach" } + SkelToolButton { label: "Split"; action: "split" } + SkelToolButton { label: "Connect"; action: "connect" } + SkelToolButton { label: "Attach"; action: "attach" } + SkelToolButton { label: "Remove"; action: "remove" } + SkelToolButton { label: "Rename"; action: "rename" } + } } Text { @@ -7743,6 +7772,211 @@ Rectangle { } } + Loader { + id: reparentBoneLoader + active: false + property bool wired: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/ReparentBoneDialog.qml" + onLoaded: { + if (!item) return + if (!reparentBoneLoader.wired) { + reparentBoneLoader.wired = true + item.reparentRequested.connect(function(newParent, keepWorld) { + if (SkeletonEditor.reparentSelectedBone(newParent, keepWorld)) { + root.boneEditStatus = "Bone reparented." + root.boneEditError = false + } else { + root.boneEditError = true + root.boneEditStatus = "Reparent failed." + } + }) + } + item.openForBone(AnimationControlController.selectedBone, + SkeletonEditor.reparentCandidateParents(), + SkeletonEditor.selectedBoneParentName()) + } + } + function openReparentBoneDialog() { + if (AnimationControlController.selectedBone.length === 0) { + root.boneEditError = true + root.boneEditStatus = "Select a bone first." + return + } + if (!reparentBoneLoader.active) { + reparentBoneLoader.active = true + } else if (reparentBoneLoader.item) { + reparentBoneLoader.item.openForBone(AnimationControlController.selectedBone, + SkeletonEditor.reparentCandidateParents(), + SkeletonEditor.selectedBoneParentName()) + } + } + + Loader { + id: splitBoneLoader + active: false + property bool wired: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/SplitBoneDialog.qml" + onLoaded: { + if (!item) return + if (!splitBoneLoader.wired) { + splitBoneLoader.wired = true + item.splitRequested.connect(function(t) { + if (SkeletonEditor.splitSelectedBone(t)) { + root.boneEditStatus = "Bone split." + root.boneEditError = false + } else { + root.boneEditError = true + root.boneEditStatus = "Split failed." + } + }) + } + item.openForBone(AnimationControlController.selectedBone) + } + } + function openSplitBoneDialog() { + if (AnimationControlController.selectedBone.length === 0) { + root.boneEditError = true + root.boneEditStatus = "Select a bone first." + return + } + if (!splitBoneLoader.active) { + splitBoneLoader.active = true + } else if (splitBoneLoader.item) { + splitBoneLoader.item.openForBone(AnimationControlController.selectedBone) + } + } + + Loader { + id: attachBoneLoader + active: false + property bool wired: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/AttachBoneDialog.qml" + onLoaded: { + if (!item) return + if (!attachBoneLoader.wired) { + attachBoneLoader.wired = true + item.attachRequested.connect(function(dstName) { + if (SkeletonEditor.attachSelectedBoneToEntity(dstName)) { + root.boneEditStatus = "Bone attached to " + dstName + "." + root.boneEditError = false + } else { + root.boneEditError = true + root.boneEditStatus = "Attach failed." + } + }) + } + item.openForBone(AnimationControlController.selectedBone, + SkeletonEditor.attachTargetEntities()) + } + } + function openAttachBoneDialog() { + if (AnimationControlController.selectedBone.length === 0) { + root.boneEditError = true + root.boneEditStatus = "Select a bone first." + return + } + if (!attachBoneLoader.active) { + attachBoneLoader.active = true + } else if (attachBoneLoader.item) { + attachBoneLoader.item.openForBone(AnimationControlController.selectedBone, + SkeletonEditor.attachTargetEntities()) + } + } + + function runSkeletonToolAction(action) { + root.boneEditError = false + root.boneEditStatus = "" + if (action === "create") { + if (SkeletonEditor.createBoneForSelected("")) { + root.boneEditStatus = "Bone created." + } else { + root.boneEditError = true + root.boneEditStatus = "Could not create bone." + } + } else if (action === "duplicate") { + if (SkeletonEditor.duplicateSelectedBone()) { + root.boneEditStatus = "Bone duplicated." + } else { + root.boneEditError = true + root.boneEditStatus = "Select a bone first." + } + } else if (action === "reparent") { + root.openReparentBoneDialog() + } else if (action === "detach") { + if (SkeletonEditor.detachSelectedBone()) { + root.boneEditStatus = "Bone detached." + } else { + root.boneEditError = true + root.boneEditStatus = "Detach failed." + } + } else if (action === "split") { + root.openSplitBoneDialog() + } else if (action === "connect") { + const want = !SkeletonEditor.isSelectedBoneConnected() + if (SkeletonEditor.setSelectedBoneConnected(want)) { + root.boneEditStatus = want ? "Bone connected." : "Bone disconnected." + root.refreshSelectedBoneConnected() + } else { + root.boneEditError = true + root.boneEditStatus = "Connect/disconnect failed." + } + } else if (action === "attach") { + root.openAttachBoneDialog() + } else if (action === "remove") { + root.openRemoveBoneDialog() + } else if (action === "rename") { + root.openRenameBoneDialog() + } + } + + Loader { + id: boneContextMenuLoader + active: false + property bool wired: false + source: "qrc:/MaterialEditorQML/BoneContextMenu.qml" + onLoaded: { + if (!item) return + if (!boneContextMenuLoader.wired) { + boneContextMenuLoader.wired = true + item.setParentRequested.connect(function() { root.openReparentBoneDialog() }) + item.detachRequested.connect(function() { root.runSkeletonToolAction("detach") }) + item.splitRequested.connect(function() { root.openSplitBoneDialog() }) + item.connectToggleRequested.connect(function() { root.runSkeletonToolAction("connect") }) + item.attachRequested.connect(function() { root.openAttachBoneDialog() }) + item.duplicateRequested.connect(function() { root.runSkeletonToolAction("duplicate") }) + item.renameRequested.connect(function() { root.openRenameBoneDialog() }) + item.removeRequested.connect(function() { root.openRemoveBoneDialog() }) + } + if (boneContextMenuLoader.pendingX !== undefined) { + item.openAt(boneContextMenuLoader.pendingX, boneContextMenuLoader.pendingY) + boneContextMenuLoader.pendingX = undefined + boneContextMenuLoader.pendingY = undefined + } + } + property var pendingX + property var pendingY + } + + function openBoneContextMenu(globalX, globalY) { + if (!boneContextMenuLoader.active) { + boneContextMenuLoader.pendingX = globalX + boneContextMenuLoader.pendingY = globalY + boneContextMenuLoader.active = true + } else if (boneContextMenuLoader.item) { + boneContextMenuLoader.item.openAt(globalX, globalY) + } + } + + Connections { + target: SkeletonEditor + function onBoneContextMenuRequested(globalX, globalY) { + root.openBoneContextMenu(globalX, globalY) + } + } + Loader { id: isometricSpritesLoader active: false diff --git a/qml/ReparentBoneDialog.qml b/qml/ReparentBoneDialog.qml new file mode 100644 index 000000000..f705206ef --- /dev/null +++ b/qml/ReparentBoneDialog.qml @@ -0,0 +1,335 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import PropertiesPanel 1.0 + +Window { + id: dialog + title: "Reparent Bone" + width: 400 + height: 360 + minimumWidth: 360 + minimumHeight: 300 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property string boneName: "" + property string currentParent: "" + property var parentCandidates: [] + property string selectedParent: "" + + signal reparentRequested(string newParentName, bool keepWorld) + signal cancelled() + + function openForBone(name, candidates, currentParentName) { + dialog.boneName = name || "" + dialog.currentParent = currentParentName || "" + dialog.parentCandidates = candidates || [] + dialog.selectedParent = dialog.parentCandidates.length > 0 + ? dialog.parentCandidates[0] : "" + parentFilter.text = "" + parentPicker.dropdownOpen = true + keepWorldCheck.checked = true + dialog.show() + dialog.raise() + dialog.requestActivate() + } + + function submit() { + if (dialog.selectedParent.length === 0) + return + dialog.reparentRequested(dialog.selectedParent, keepWorldCheck.checked) + dialog.close() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: 12 + color: PropertiesPanelController.textColor + text: dialog.boneName.length > 0 + ? "Set a new parent for \"" + dialog.boneName + "\":" + : "Set a new parent for the selected bone:" + } + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: 11 + color: PropertiesPanelController.textColor + opacity: 0.85 + text: dialog.currentParent.length > 0 + ? "Current parent: " + dialog.currentParent + : "Current parent: (root)" + } + + Text { + text: "New parent" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + opacity: 0.8 + } + + // Searchable bone picker (same pattern as Inspector bone dropdown) + Item { + id: parentPicker + Layout.fillWidth: true + Layout.preferredHeight: 24 + Layout.fillHeight: dropdownOpen + property bool dropdownOpen: true + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + radius: 3 + color: PropertiesPanelController.inputColor + border.color: parentPicker.dropdownOpen + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + + Row { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 4 + spacing: 4 + Text { + text: dialog.selectedParent.length > 0 + ? dialog.selectedParent : "(none)" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 18 + } + Text { + text: parentPicker.dropdownOpen ? "\u25B2" : "\u25BC" + color: PropertiesPanelController.textColor + font.pixelSize: 8 + anchors.verticalCenter: parent.verticalCenter + } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + parentPicker.dropdownOpen = !parentPicker.dropdownOpen + if (parentPicker.dropdownOpen) { + parentFilter.text = "" + parentFilter.forceActiveFocus() + } + } + } + } + + Rectangle { + visible: parentPicker.dropdownOpen + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 160 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + + Column { + anchors.fill: parent + anchors.margins: 1 + spacing: 0 + + Rectangle { + width: parent.width + height: 26 + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + + TextInput { + id: parentFilter + anchors.fill: parent + anchors.margins: 4 + color: PropertiesPanelController.textColor + font.pixelSize: 11 + clip: true + verticalAlignment: TextInput.AlignVCenter + + property var filtered: { + var q = text.toLowerCase() + var bones = dialog.parentCandidates + if (!bones || bones.length === 0) return [] + if (q.length === 0) return bones + var r = [] + for (var i = 0; i < bones.length; i++) + if (bones[i].toLowerCase().indexOf(q) >= 0) + r.push(bones[i]) + return r + } + + Keys.onEscapePressed: parentPicker.dropdownOpen = false + Keys.onReturnPressed: { + if (filtered.length > 0) { + dialog.selectedParent = filtered[0] + parentPicker.dropdownOpen = false + } + } + } + + Text { + anchors.fill: parent + anchors.margins: 4 + text: "Type to filter bones…" + font.pixelSize: 11 + font.italic: true + color: PropertiesPanelController.borderColor + visible: parentFilter.text.length === 0 && !parentFilter.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + ListView { + id: parentListView + width: parent.width + height: parent.height - 26 + clip: true + model: parentFilter.filtered + ScrollBar.vertical: ScrollBar { + policy: parentListView.contentHeight > parentListView.height + ? ScrollBar.AlwaysOn : ScrollBar.AsNeeded + } + + delegate: Rectangle { + width: parentListView.width + height: 22 + color: { + if (parentDelegateMouse.containsMouse) + return PropertiesPanelController.highlightColor + if (modelData === dialog.selectedParent) + return Qt.rgba( + PropertiesPanelController.highlightColor.r, + PropertiesPanelController.highlightColor.g, + PropertiesPanelController.highlightColor.b, 0.35) + return PropertiesPanelController.inputColor + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: modelData + color: parentDelegateMouse.containsMouse + ? "white" : PropertiesPanelController.textColor + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width - 12 + } + MouseArea { + id: parentDelegateMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + dialog.selectedParent = modelData + parentPicker.dropdownOpen = false + } + } + } + } + } + } + } + } + + CheckBox { + id: keepWorldCheck + checked: true + text: "Keep world transform" + font.pixelSize: 11 + spacing: 6 + indicator: Rectangle { + x: keepWorldCheck.leftPadding + y: keepWorldCheck.height / 2 - height / 2 + implicitWidth: 16 + implicitHeight: 16 + radius: 2 + color: keepWorldCheck.checked + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.inputColor + border.color: keepWorldCheck.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: keepWorldCheck.activeFocus ? 2 : 1 + Text { + anchors.centerIn: parent + visible: keepWorldCheck.checked + text: "✓" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + font.bold: true + } + } + contentItem: Text { + text: keepWorldCheck.text + color: PropertiesPanelController.textColor + font.pixelSize: 11 + leftPadding: keepWorldCheck.indicator.width + keepWorldCheck.spacing + verticalAlignment: Text.AlignVCenter + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: 8 + Rectangle { + width: cancelLabel.implicitWidth + 20 + height: 28 + radius: 3 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + Text { + id: cancelLabel + anchors.centerIn: parent + text: "Cancel" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { dialog.cancelled(); dialog.close() } + } + } + Rectangle { + width: okLabel.implicitWidth + 20 + height: 28 + radius: 3 + opacity: dialog.selectedParent.length > 0 ? 1.0 : 0.45 + color: PropertiesPanelController.highlightColor + Text { + id: okLabel + anchors.centerIn: parent + text: "Reparent" + color: "white" + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + enabled: dialog.selectedParent.length > 0 + cursorShape: Qt.PointingHandCursor + onClicked: dialog.submit() + } + } + } + } +} diff --git a/qml/SplitBoneDialog.qml b/qml/SplitBoneDialog.qml new file mode 100644 index 000000000..adb148c58 --- /dev/null +++ b/qml/SplitBoneDialog.qml @@ -0,0 +1,118 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import PropertiesPanel 1.0 + +Window { + id: dialog + title: "Split Bone" + width: 380 + height: 180 + minimumWidth: 340 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property string boneName: "" + property real fraction: 0.5 + + signal splitRequested(real t) + signal cancelled() + + function openForBone(name) { + dialog.boneName = name || "" + dialog.fraction = 0.5 + fracSlider.value = 0.5 + dialog.show() + dialog.raise() + dialog.requestActivate() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + font.pixelSize: 12 + color: PropertiesPanelController.textColor + text: dialog.boneName.length > 0 + ? "Split \"" + dialog.boneName + "\" along its axis:" + : "Split the selected bone along its axis:" + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Text { + text: "Fraction" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + } + Slider { + id: fracSlider + Layout.fillWidth: true + from: 0.05 + to: 0.95 + value: 0.5 + onValueChanged: dialog.fraction = value + } + Text { + text: dialog.fraction.toFixed(2) + color: PropertiesPanelController.textColor + font.pixelSize: 12 + Layout.preferredWidth: 36 + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: 8 + Rectangle { + width: cancelLabel.implicitWidth + 20 + height: 28 + radius: 3 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + Text { + id: cancelLabel + anchors.centerIn: parent + text: "Cancel" + color: PropertiesPanelController.textColor + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { dialog.cancelled(); dialog.close() } + } + } + Rectangle { + width: okLabel.implicitWidth + 20 + height: 28 + radius: 3 + color: PropertiesPanelController.highlightColor + Text { + id: okLabel + anchors.centerIn: parent + text: "Split" + color: "white" + font.pixelSize: 12 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + dialog.splitRequested(dialog.fraction) + dialog.close() + } + } + } + } + } +} diff --git a/qml/qmldir b/qml/qmldir index b71c95bc9..728423897 100644 --- a/qml/qmldir +++ b/qml/qmldir @@ -12,6 +12,10 @@ QuadRetopoDialog 1.0 QuadRetopoDialog.qml SkinWeightsDialog 1.0 SkinWeightsDialog.qml RemoveBoneDialog 1.0 RemoveBoneDialog.qml RenameBoneDialog 1.0 RenameBoneDialog.qml +ReparentBoneDialog 1.0 ReparentBoneDialog.qml +SplitBoneDialog 1.0 SplitBoneDialog.qml +AttachBoneDialog 1.0 AttachBoneDialog.qml +BoneContextMenu 1.0 BoneContextMenu.qml MaterialListModal 1.0 MaterialListModal.qml ThemedButton 1.0 ThemedButton.qml ThemedCheckBox 1.0 ThemedCheckBox.qml diff --git a/src/SkeletonDebug.cpp b/src/SkeletonDebug.cpp index 287cae68e..08f540f20 100755 --- a/src/SkeletonDebug.cpp +++ b/src/SkeletonDebug.cpp @@ -1,6 +1,7 @@ #include "SkeletonDebug.h" #include "GlobalDefinitions.h" +#include #include #include @@ -53,6 +54,8 @@ SkeletonDebug::SkeletonDebug(Ogre::Entity* entity, Ogre::SceneManager *man, floa createBoneMaterial(); createAxesMesh(); createBoneMesh(); + createJointMesh(); + createLinkMesh(); mBoneVisualByName = createBoneVisuals(); @@ -70,10 +73,18 @@ void SkeletonDebug::onTimerTick() return; short currentSelected = -1; + std::string selectedName; + for (auto* ent : mBoneEntities) { ent->setMaterial(mBoneMatPtr); ent->setVisible(mShowBones); } + // Dim hierarchy links so joint spheres read as the bone location. + for (auto* ent : mBoneEntities) { + if (ent->getMesh() && ent->getMesh()->getName() == "SkeletonDebug/LinkMesh") + ent->setMaterial(mLinkMatPtr); + } + for (Ogre::Bone* root : mEntity->getSkeleton()->getRootBones()) { auto it = mBoneVisualByName.find(root->getName()); if (it != mBoneVisualByName.end() && it->second) { @@ -88,13 +99,15 @@ void SkeletonDebug::onTimerTick() continue; currentSelected = bone->getHandle(); + selectedName = bone->getName(); - auto it = mBoneVisualByName.find(bone->getName()); - if (it == mBoneVisualByName.end() || !it->second) - continue; - - it->second->setMaterial(mBoneMatSelectedPtr); - it->second->setVisible(mShowBones); + // Highlight the joint and its outbound links (not sibling links). + for (auto* ent : mBoneEntities) { + if (boneNameForMovable(ent) == selectedName) { + ent->setMaterial(mBoneMatSelectedPtr); + ent->setVisible(mShowBones); + } + } } if (currentSelected != mLastSelectedBone) { @@ -156,22 +169,33 @@ SkeletonDebug::~SkeletonDebug() mAxisEntities.clear(); } -void SkeletonDebug::createChildBoneRepresentations(const Ogre::Bone* pBone, Ogre::Entity*& lastEnt) +void SkeletonDebug::createChildLinks(const Ogre::Bone* pBone) { - for(unsigned short i = 0; i < pBone->numChildren(); ++i) + // Thin rods parent→child. These are hierarchy edges, not extra bones — + // the joint sphere at the parent is the actual bone location. + for (unsigned short i = 0; i < pBone->numChildren(); ++i) { - float length = pBone->getChild(i)->getInitialPosition().length(); - if(length < 0.00001f) + Ogre::Node* childNode = pBone->getChild(i); + if (!childNode) + continue; + + const Ogre::Vector3 childPos = childNode->getInitialPosition(); + const float length = childPos.length(); + if (length < 0.00001f) continue; - lastEnt = mSceneMan->createEntity("SkeletonDebug/BoneMesh"); - auto* tp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)lastEnt); - mBoneEntities.push_back(lastEnt); - tp->setScale(length, length, length); - // Tag every child bone visual with the parent bone's name — - // dragging this segment in the viewport edits the parent bone's - // pose (the segment visually represents that bone, not the child). - tagBoneVisual(lastEnt, pBone->getName(), mEntity->getName()); + Ogre::Entity* link = mSceneMan->createEntity("SkeletonDebug/LinkMesh"); + auto* tp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)link); + mBoneEntities.push_back(link); + + const Ogre::Vector3 dir = childPos / length; + tp->setOrientation(Ogre::Vector3::UNIT_Y.getRotationTo(dir)); + // Link mesh is unit length along +Y; keep thickness constant so + // multi-child fans don't look like a bundle of full bones. + const float thick = std::max(mBoneSize * 0.35f, 0.008f); + tp->setScale(thick, length, thick); + + tagBoneVisual(link, pBone->getName(), mEntity->getName()); } } @@ -180,43 +204,64 @@ std::map> SkeletonDebug::createBoneVisua std::map> mapEntities; int numBones = mEntity->getSkeleton()->getNumBones(); - for(unsigned short int iBone = 0; iBone < numBones; ++iBone) + Ogre::Vector3 entityScale = Ogre::Vector3::UNIT_SCALE; + if (mEntity->getParentSceneNode()) + entityScale = mEntity->getParentSceneNode()->getScale(); + const Ogre::Vector3 scaleMagnitude( + std::max(std::abs(entityScale.x), 1e-4f), + std::max(std::abs(entityScale.y), 1e-4f), + std::max(std::abs(entityScale.z), 1e-4f)); + const float invEntScale = 1.f + / std::max({scaleMagnitude.x, scaleMagnitude.y, scaleMagnitude.z}); + + for (unsigned short int iBone = 0; iBone < numBones; ++iBone) { const Ogre::Bone* pBone = mEntity->getSkeleton()->getBone(iBone); - if(!pBone) + if (!pBone) { assert(false); continue; } - Ogre::Entity *ent = nullptr; - - if(unsigned short numChildren = pBone->numChildren(); numChildren == 0) - { - ent = mSceneMan->createEntity("SkeletonDebug/BoneMesh"); - auto* tp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)ent); - mBoneEntities.push_back(ent); - - float length = pBone->getInitialPosition().length(); - if(length >= 0.00001f) - tp->setScale(length, length, length); - - tagBoneVisual(ent, pBone->getName(), mEntity->getName()); - } - else - { - createChildBoneRepresentations(pBone, ent); + // Same sizing rule as the old octahedron bones: scale from the + // bone's length (avg distance to children, or inbound offset for leaves). + float length = 0.f; + const unsigned short numChildren = pBone->numChildren(); + if (numChildren > 0) { + for (unsigned short i = 0; i < numChildren; ++i) { + if (auto* child = pBone->getChild(i)) + length += child->getInitialPosition().length(); + } + length /= static_cast(numChildren); + } else { + length = pBone->getInitialPosition().length(); } - - mapEntities[pBone->getName().data()] = ent; - - ent = mSceneMan->createEntity("SkeletonDebug/AxesMesh"); - auto* tp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)ent); - tp->setScale((mScaleAxes/mEntity->getParentSceneNode()->getScale().x), (mScaleAxes/mEntity->getParentSceneNode()->getScale().y), (mScaleAxes/mEntity->getParentSceneNode()->getScale().z)); - mAxisEntities.push_back(ent); - // Tag the axes overlay too — clicking the axis cross is the most - // visible target for users when scaling/rotating. - tagBoneVisual(ent, pBone->getName(), mEntity->getName()); + if (length < 1e-5f) + length = mBoneSize; + + // Unit joint mesh → world radius ≈ length * mBoneSize (matches the + // old setScale(length) on a mesh built in mBoneSize units). Clamp so + // tiny bones stay pickable and long ones don't swallow neighbours. + const float jointScale = std::clamp(length * mBoneSize, mBoneSize * 0.2f, mBoneSize * 0.75f) + * invEntScale; + + Ogre::Entity* joint = mSceneMan->createEntity("SkeletonDebug/JointMesh"); + auto* jointTp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)joint); + jointTp->setScale(jointScale, jointScale, jointScale); + mBoneEntities.push_back(joint); + tagBoneVisual(joint, pBone->getName(), mEntity->getName()); + mapEntities[pBone->getName().data()] = joint; + + if (numChildren > 0) + createChildLinks(pBone); + + Ogre::Entity* axes = mSceneMan->createEntity("SkeletonDebug/AxesMesh"); + auto* axesTp = mEntity->attachObjectToBone(pBone->getName(), (Ogre::MovableObject*)axes); + axesTp->setScale(mScaleAxes / scaleMagnitude.x, + mScaleAxes / scaleMagnitude.y, + mScaleAxes / scaleMagnitude.z); + mAxisEntities.push_back(axes); + tagBoneVisual(axes, pBone->getName(), mEntity->getName()); } return mapEntities; @@ -351,6 +396,24 @@ void SkeletonDebug::createBoneMaterial() p->setAmbient(1, 0.85f, 0); p->setEmissive(1, 0.85f, 0); } + + // Dimmer material for hierarchy link rods (edges, not joints). + Ogre::String linkMatName = "SkeletonDebug/LinkMat"; + mLinkMatPtr = Ogre::static_pointer_cast( + Ogre::MaterialManager::getSingleton().getByName(linkMatName)); + if (!mLinkMatPtr) { + mLinkMatPtr = Ogre::static_pointer_cast( + Ogre::MaterialManager::getSingleton().create( + linkMatName, Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME)); + Ogre::Pass* p = mLinkMatPtr->getTechnique(0)->getPass(0); + p->setLightingEnabled(false); + p->setPolygonModeOverrideable(false); + p->setVertexColourTracking(Ogre::TVC_AMBIENT); + p->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA); + p->setCullingMode(Ogre::CULL_NONE); + p->setDepthWriteEnabled(false); + p->setDepthCheckEnabled(false); + } } void SkeletonDebug::createBoneMesh() @@ -449,6 +512,76 @@ void SkeletonDebug::createBoneMesh() } } +void SkeletonDebug::createJointMesh() +{ + // Unit octahedron centered at origin — scaled at attach time. + const Ogre::String meshName = "SkeletonDebug/JointMesh"; + mJointMeshPtr = Ogre::static_pointer_cast( + Ogre::MeshManager::getSingleton().getByName(meshName)); + if (mJointMeshPtr) + return; + + Ogre::ManualObject mo("tmpJoint"); + mo.begin(mBoneMatPtr->getName()); + const Ogre::ColourValue col(0.85f, 0.85f, 0.9f, 1.0f); + const std::array v = { + Ogre::Vector3( 1, 0, 0), + Ogre::Vector3(-1, 0, 0), + Ogre::Vector3( 0, 1, 0), + Ogre::Vector3( 0,-1, 0), + Ogre::Vector3( 0, 0, 1), + Ogre::Vector3( 0, 0,-1), + }; + auto tri = [&](int a, int b, int c) { + mo.position(v[a]); mo.colour(col); + mo.position(v[b]); mo.colour(col); + mo.position(v[c]); mo.colour(col); + }; + tri(0, 2, 4); tri(0, 4, 3); tri(0, 3, 5); tri(0, 5, 2); + tri(1, 4, 2); tri(1, 3, 4); tri(1, 5, 3); tri(1, 2, 5); + for (unsigned short i = 0; i < 8; ++i) + mo.triangle(i * 3, i * 3 + 1, i * 3 + 2); + mo.end(); + mJointMeshPtr = mo.convertToMesh(meshName, Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME); +} + +void SkeletonDebug::createLinkMesh() +{ + // Unit-length thin octahedron along +Y [0..1]. Scaled: (thick, length, thick). + const Ogre::String meshName = "SkeletonDebug/LinkMesh"; + mLinkMeshPtr = Ogre::static_pointer_cast( + Ogre::MeshManager::getSingleton().getByName(meshName)); + if (mLinkMeshPtr) + return; + + Ogre::ManualObject mo("tmpLink"); + mo.begin(mLinkMatPtr ? mLinkMatPtr->getName() : mBoneMatPtr->getName()); + const Ogre::ColourValue col(0.45f, 0.45f, 0.5f, 0.85f); + const Ogre::ColourValue col1(0.55f, 0.55f, 0.6f, 0.85f); + const float r = 0.5f; + const std::array basepos = { + Ogre::Vector3(0, 0, 0), + Ogre::Vector3( r, 0.5f, r), + Ogre::Vector3(-r, 0.5f, r), + Ogre::Vector3(-r, 0.5f, -r), + Ogre::Vector3( r, 0.5f, -r), + Ogre::Vector3(0, 1, 0), + }; + auto tri = [&](int a, int b, int c, const Ogre::ColourValue& cval) { + mo.position(basepos[a]); mo.colour(cval); + mo.position(basepos[b]); mo.colour(cval); + mo.position(basepos[c]); mo.colour(cval); + }; + tri(0, 2, 1, col); tri(0, 3, 2, col1); + tri(0, 4, 3, col); tri(0, 1, 4, col1); + tri(1, 2, 5, col1); tri(2, 3, 5, col); + tri(3, 4, 5, col1); tri(4, 1, 5, col); + for (unsigned short i = 0; i < 8; ++i) + mo.triangle(i * 3, i * 3 + 1, i * 3 + 2); + mo.end(); + mLinkMeshPtr = mo.convertToMesh(meshName, Ogre::ResourceGroupManager::INTERNAL_RESOURCE_GROUP_NAME); +} + void SkeletonDebug::createAxesMesh() { Ogre::String meshName = "SkeletonDebug/AxesMesh"; diff --git a/src/SkeletonDebug.h b/src/SkeletonDebug.h index 11bbae0ff..8f43e0526 100755 --- a/src/SkeletonDebug.h +++ b/src/SkeletonDebug.h @@ -44,8 +44,8 @@ class SkeletonDebug: public QObject private: std::vector mAxisEntities; - std::vector mBoneEntities; - std::map> mBoneVisualByName; + std::vector mBoneEntities; // joints + hierarchy links + std::map> mBoneVisualByName; // joint only float mBoneSize; @@ -54,7 +54,10 @@ class SkeletonDebug: public QObject Ogre::MaterialPtr mBoneMatPtr; Ogre::MaterialPtr mBoneMatSelectedPtr; Ogre::MaterialPtr mBoneMatRootPtr; + Ogre::MaterialPtr mLinkMatPtr; Ogre::MeshPtr mBoneMeshPtr; + Ogre::MeshPtr mJointMeshPtr; + Ogre::MeshPtr mLinkMeshPtr; Ogre::MeshPtr mAxesMeshPtr; Ogre::SceneManager *mSceneMan; @@ -68,8 +71,10 @@ class SkeletonDebug: public QObject void createBoneMaterial(); void createAxesMesh(); void createBoneMesh(); + void createJointMesh(); + void createLinkMesh(); std::map> createBoneVisuals(); - void createChildBoneRepresentations(const Ogre::Bone* pBone, Ogre::Entity*& lastEnt); + void createChildLinks(const Ogre::Bone* pBone); void onTimerTick(); QTimer mTimer; diff --git a/src/SkeletonEditor.cpp b/src/SkeletonEditor.cpp index 260602638..815fc35a5 100644 --- a/src/SkeletonEditor.cpp +++ b/src/SkeletonEditor.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include @@ -25,6 +26,9 @@ namespace { +constexpr Ogre::Real kDisconnectGap = 0.1f; +constexpr Ogre::Real kConnectEpsilon = 0.05f; + Ogre::Entity* resolveEntityByName(const std::string& name) { if (name.empty()) return nullptr; @@ -37,6 +41,28 @@ Ogre::Entity* resolveEntityByName(const std::string& name) return nullptr; } +// Parent tip in parent-local space. Prefer the average of sibling bind +// positions; for a sole child the bind (initial) position IS the tip — +// disconnect keeps that initial tip and only offsets the live position. +Ogre::Vector3 parentTipLocal(Ogre::Bone* parent, Ogre::Bone* bone) +{ + Ogre::Vector3 tip = Ogre::Vector3::ZERO; + int tipCount = 0; + for (auto* sibling : parent->getChildren()) { + auto* sb = static_cast(sibling); + if (sb == bone) continue; + tip += sb->getInitialPosition(); + ++tipCount; + } + if (tipCount > 0) + return tip / static_cast(tipCount); + + Ogre::Vector3 pos = bone->getInitialPosition(); + if (pos.squaredLength() < 1e-12f) + pos = Ogre::Vector3(0, kDisconnectGap, 0); + return pos; +} + Ogre::SkeletonPtr skeletonResource(Ogre::Entity* entity) { if (!entity || !entity->getMesh() || !entity->getMesh()->hasSkeleton()) @@ -678,6 +704,441 @@ void SkeletonEditor::refreshAfterEdit(const std::string& entityName, const QStri emit editor->skeletonStructureChanged(); } +bool SkeletonEditor::ensureEntitySkeleton(Ogre::Entity* entity, QString* error) +{ + if (!entity || !entity->getMesh()) { + if (error) *error = QStringLiteral("No entity/mesh"); + return false; + } + if (entity->getMesh()->hasSkeleton() && entity->getMesh()->getSkeleton()) + return true; + + auto& skelMgr = Ogre::SkeletonManager::getSingleton(); + const std::string skelName = entity->getMesh()->getName() + "_skel"; + if (skelMgr.resourceExists(skelName)) + skelMgr.remove(skelName); + Ogre::SkeletonPtr skel = skelMgr.create( + skelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, true); + Ogre::Bone* root = skel->createBone("Root", 0); + root->setPosition(Ogre::Vector3::ZERO); + root->setOrientation(Ogre::Quaternion::IDENTITY); + root->setScale(Ogre::Vector3::UNIT_SCALE); + root->setInitialState(); + skel->setBindingPose(); + skel->_fireLoadingComplete(); + entity->getMesh()->_notifySkeleton(skel); + entity->_initialise(true); + return true; +} + +SkeletonEditor::Result SkeletonEditor::reparentBone(Ogre::Entity* entity, + const QString& boneName, + const QString& newParentName, + const ReparentOptions& opts) +{ + Result result; + Ogre::SkeletonPtr skel = skeletonResource(entity); + if (!skel || boneName.isEmpty()) { + result.error = QStringLiteral("Invalid entity or bone name"); + return result; + } + const std::string boneStd = boneName.toStdString(); + if (!skel->hasBone(boneStd)) { + result.error = QStringLiteral("Bone not found: %1").arg(boneName); + return result; + } + if (!newParentName.isEmpty() && !skel->hasBone(newParentName.toStdString())) { + result.error = QStringLiteral("Parent bone not found: %1").arg(newParentName); + return result; + } + if (boneName == newParentName) { + result.error = QStringLiteral("Cannot parent a bone to itself"); + return result; + } + if (!newParentName.isEmpty()) { + const auto descendants = collectDescendants(skel.get(), boneStd); + if (descendants.count(newParentName.toStdString())) { + result.error = QStringLiteral("Cannot parent under a descendant (cycle)"); + return result; + } + } + + Ogre::Bone* bone = skel->getBone(boneStd); + const std::string oldParent = bone->getParent() ? bone->getParent()->getName() : std::string{}; + const std::string newParentStd = newParentName.toStdString(); + if (oldParent == newParentStd) { + result.ok = true; + result.boneName = boneName; + return result; + } + + Snapshot snap = captureSnapshot(entity); + Snapshot::BoneData* target = nullptr; + for (auto& bd : snap.bones) { + if (bd.name == boneStd) { + target = &bd; + break; + } + } + if (!target) { + result.error = QStringLiteral("Bone missing from snapshot"); + return result; + } + + if (opts.keepWorld && !newParentStd.empty() && skel->hasBone(newParentStd)) { + Ogre::Bone* newParent = skel->getBone(newParentStd); + worldToNewParentLocal(bone, newParent, + target->position, target->orientation, target->scale); + skel->setBindingPose(); + worldToNewParentLocal(bone, newParent, + target->initialPosition, target->initialOrientation, target->initialScale); + } else if (opts.keepWorld && newParentStd.empty()) { + // Detach to root: convert world → skeleton-root local (identity parent). + target->position = bone->_getDerivedPosition(); + target->orientation = bone->_getDerivedOrientation(); + target->scale = bone->_getDerivedScale(); + skel->setBindingPose(); + target->initialPosition = bone->_getDerivedPosition(); + target->initialOrientation = bone->_getDerivedOrientation(); + target->initialScale = bone->_getDerivedScale(); + } + // keep-local: leave TRS as captured; only parentName changes. + + target->parentName = newParentStd; + + QString err; + if (!restoreSnapshot(entity, snap, &err)) { + result.error = err; + return result; + } + result.ok = true; + result.boneName = boneName; + return result; +} + +SkeletonEditor::Result SkeletonEditor::detachBone(Ogre::Entity* entity, const QString& boneName) +{ + ReparentOptions opts; + opts.keepWorld = true; + return reparentBone(entity, boneName, {}, opts); +} + +SkeletonEditor::Result SkeletonEditor::splitBone(Ogre::Entity* entity, + const QString& boneName, + float t) +{ + Result result; + t = std::clamp(t, 0.05f, 0.95f); + Ogre::SkeletonPtr skel = skeletonResource(entity); + if (!skel || boneName.isEmpty()) { + result.error = QStringLiteral("Invalid entity or bone name"); + return result; + } + const std::string boneStd = boneName.toStdString(); + if (!skel->hasBone(boneStd)) { + result.error = QStringLiteral("Bone not found: %1").arg(boneName); + return result; + } + + Ogre::Bone* bone = skel->getBone(boneStd); + Ogre::Vector3 axis = Ogre::Vector3::ZERO; + const auto& children = bone->getChildren(); + if (!children.empty()) { + for (auto* child : children) + axis += static_cast(child)->getInitialPosition(); + axis /= static_cast(children.size()); + } + if (axis.squaredLength() < 1e-10f) + axis = Ogre::Vector3(0, 0.1f, 0); + + Snapshot snap = captureSnapshot(entity); + const unsigned short oldHandle = bone->getHandle(); + const QString childName = uniqueBoneName(skel.get(), boneName + QStringLiteral("_split")); + const unsigned short newHandle = nextBoneHandle(skel.get()); + + Snapshot::BoneData splitBd; + splitBd.name = childName.toStdString(); + splitBd.handle = newHandle; + splitBd.parentName = boneStd; + // Insert the new joint at fraction t along bone→tip (axis). + splitBd.position = axis * t; + splitBd.orientation = Ogre::Quaternion::IDENTITY; + splitBd.scale = Ogre::Vector3::UNIT_SCALE; + splitBd.initialPosition = splitBd.position; + splitBd.initialOrientation = Ogre::Quaternion::IDENTITY; + splitBd.initialScale = Ogre::Vector3::UNIT_SCALE; + + // Original bone keeps its parent; former children move under the split + // joint and are re-expressed relative to that joint (world tip unchanged). + for (auto& bd : snap.bones) { + if (bd.parentName == boneStd) { + bd.parentName = splitBd.name; + bd.position = bd.position - axis * t; + bd.initialPosition = bd.initialPosition - axis * t; + } + } + snap.bones.push_back(splitBd); + + auto remapAssignments = [&](std::vector& assignments) { + std::vector out; + out.reserve(assignments.size() * 2); + for (const auto& vba : assignments) { + if (vba.boneIndex != oldHandle) { + out.push_back(vba); + continue; + } + Ogre::VertexBoneAssignment a = vba; + a.weight = vba.weight * (1.f - t); + Ogre::VertexBoneAssignment b = vba; + b.boneIndex = newHandle; + b.weight = vba.weight * t; + if (a.weight > 1e-8f) out.push_back(a); + if (b.weight > 1e-8f) out.push_back(b); + } + assignments.swap(out); + }; + remapAssignments(snap.meshAssignments); + for (auto& sub : snap.submeshAssignments) + remapAssignments(sub); + + QString err; + if (!restoreSnapshot(entity, snap, &err)) { + result.error = err; + return result; + } + result.ok = true; + result.boneName = childName; + return result; +} + +bool SkeletonEditor::isBoneConnected(Ogre::Entity* entity, const QString& boneName) +{ + Ogre::SkeletonPtr skel = skeletonResource(entity); + if (!skel || boneName.isEmpty() || !skel->hasBone(boneName.toStdString())) + return false; + Ogre::Bone* bone = skel->getBone(boneName.toStdString()); + Ogre::Bone* parent = static_cast(bone->getParent()); + if (!parent) return true; // roots are trivially "connected" + + const Ogre::Vector3 tip = parentTipLocal(parent, bone); + // Live head vs parent tip. Disconnect keeps bind (initial) at the tip and + // only offsets position, so sole children remain detectable. + const Ogre::Real gap = (bone->getPosition() - tip).length(); + return gap < kConnectEpsilon; +} + +SkeletonEditor::Result SkeletonEditor::setBoneConnected(Ogre::Entity* entity, + const QString& boneName, + bool connected) +{ + Result result; + Ogre::SkeletonPtr skel = skeletonResource(entity); + if (!skel || boneName.isEmpty()) { + result.error = QStringLiteral("Invalid entity or bone name"); + return result; + } + const std::string boneStd = boneName.toStdString(); + if (!skel->hasBone(boneStd)) { + result.error = QStringLiteral("Bone not found: %1").arg(boneName); + return result; + } + Ogre::Bone* bone = skel->getBone(boneStd); + Ogre::Bone* parent = static_cast(bone->getParent()); + if (!parent) { + result.ok = true; + result.boneName = boneName; + return result; + } + + Snapshot snap = captureSnapshot(entity); + Snapshot::BoneData* target = nullptr; + for (auto& bd : snap.bones) { + if (bd.name == boneStd) { + target = &bd; + break; + } + } + if (!target) { + result.error = QStringLiteral("Bone missing from snapshot"); + return result; + } + + const Ogre::Vector3 tip = parentTipLocal(parent, bone); + Ogre::Vector3 dir = tip; + if (dir.squaredLength() < 1e-10f) + dir = Ogre::Vector3::UNIT_Y; + else + dir.normalise(); + + if (connected) { + target->position = tip; + target->initialPosition = tip; + } else { + // Keep bind tip in initialPosition; offset only the live pose so + // isBoneConnected can recover the tip for sole children. + target->initialPosition = tip; + target->position = tip + dir * kDisconnectGap; + } + + QString err; + if (!restoreSnapshot(entity, snap, &err)) { + result.error = err; + return result; + } + result.ok = true; + result.boneName = boneName; + return result; +} + +SkeletonEditor::Result SkeletonEditor::attachBonesToEntity(Ogre::Entity* srcEntity, + const QStringList& boneNames, + Ogre::Entity* dstEntity, + const AttachOptions& opts) +{ + Q_UNUSED(opts); + Result result; + if (!srcEntity || !dstEntity || boneNames.isEmpty()) { + result.error = QStringLiteral("Invalid attach arguments"); + return result; + } + if (srcEntity == dstEntity) { + result.error = QStringLiteral("Source and destination must differ"); + return result; + } + Ogre::SkeletonPtr srcSkel = skeletonResource(srcEntity); + if (!srcSkel) { + result.error = QStringLiteral("Source has no skeleton"); + return result; + } + QString err; + // Validate sources before mutating destination (ensureEntitySkeleton + // may create a skeleton — don't leave that behind on a bad request). + std::unordered_set toCopy; + for (const QString& name : boneNames) { + if (!srcSkel->hasBone(name.toStdString())) { + result.error = QStringLiteral("Source bone not found: %1").arg(name); + return result; + } + auto desc = collectDescendants(srcSkel.get(), name.toStdString()); + toCopy.insert(desc.begin(), desc.end()); + } + if (toCopy.empty()) { + result.error = QStringLiteral("No bones to attach"); + return result; + } + + if (!ensureEntitySkeleton(dstEntity, &err)) { + result.error = err; + return result; + } + + Snapshot dstSnap = captureSnapshot(dstEntity); + Ogre::SkeletonPtr dstSkel = skeletonResource(dstEntity); + if (!dstSkel) { + result.error = QStringLiteral("Destination skeleton missing after ensure"); + return result; + } + + std::unordered_map renameMap; + QString firstNewName; + unsigned short nextHandle = 0; + for (const auto& bd : dstSnap.bones) + nextHandle = std::max(nextHandle, static_cast(bd.handle + 1)); + + // Parent-before-child order (handle order is not guaranteed hierarchical). + std::vector remaining; + remaining.reserve(toCopy.size()); + for (const std::string& name : toCopy) { + if (srcSkel->hasBone(name)) + remaining.push_back(srcSkel->getBone(name)); + } + std::vector ordered; + ordered.reserve(remaining.size()); + std::unordered_set placed; + while (ordered.size() < remaining.size()) { + bool progress = false; + for (Ogre::Bone* b : remaining) { + if (!b || placed.count(b->getName())) + continue; + Ogre::Node* parent = b->getParent(); + const bool parentOk = !parent + || !toCopy.count(parent->getName()) + || placed.count(parent->getName()); + if (!parentOk) + continue; + ordered.push_back(b); + placed.insert(b->getName()); + progress = true; + } + if (!progress) + break; // Shouldn't happen for a tree; fall through with partial order. + } + for (Ogre::Bone* b : remaining) { + if (b && !placed.count(b->getName())) + ordered.push_back(b); + } + + std::string dstRootParent; + if (!dstSnap.bones.empty()) { + // Attach under first root of destination. + for (const auto& bd : dstSnap.bones) { + if (bd.parentName.empty()) { + dstRootParent = bd.name; + break; + } + } + if (dstRootParent.empty()) + dstRootParent = dstSnap.bones.front().name; + } + + for (Ogre::Bone* srcBone : ordered) { + const std::string srcName = srcBone->getName(); + QString unique = uniqueBoneName(dstSkel.get(), QString::fromStdString(srcName)); + // Also avoid collisions with bones we're about to add in this batch. + while (true) { + bool clash = false; + for (const auto& kv : renameMap) { + if (kv.second == unique.toStdString()) { clash = true; break; } + } + if (!clash) { + for (const auto& bd : dstSnap.bones) { + if (bd.name == unique.toStdString()) { clash = true; break; } + } + } + if (!clash) break; + unique = uniqueBoneName(dstSkel.get(), unique + QStringLiteral("_")); + } + renameMap[srcName] = unique.toStdString(); + if (firstNewName.isEmpty()) + firstNewName = unique; + + Snapshot::BoneData bd; + bd.name = unique.toStdString(); + bd.handle = nextHandle++; + if (srcBone->getParent() && toCopy.count(srcBone->getParent()->getName())) { + auto pit = renameMap.find(srcBone->getParent()->getName()); + bd.parentName = (pit != renameMap.end()) ? pit->second : dstRootParent; + } else { + bd.parentName = dstRootParent; + } + bd.position = srcBone->getPosition(); + bd.orientation = srcBone->getOrientation(); + bd.scale = srcBone->getScale(); + bd.initialPosition = srcBone->getInitialPosition(); + bd.initialOrientation = srcBone->getInitialOrientation(); + bd.initialScale = srcBone->getInitialScale(); + dstSnap.bones.push_back(bd); + } + + if (!restoreSnapshot(dstEntity, dstSnap, &err)) { + result.error = err; + return result; + } + result.ok = true; + result.boneName = firstNewName; + return result; +} + bool SkeletonEditor::hasSkeletonSelection() const { return selectedSkinnedEntity() != nullptr; @@ -690,6 +1151,58 @@ QString SkeletonEditor::selectedBoneName() const return {}; } +QString SkeletonEditor::selectedBoneParentName() const +{ + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return {}; + Ogre::SkeletonPtr skel = skeletonResource(entity); + if (!skel) return {}; + const QString selected = selectedBoneName(); + if (selected.isEmpty() || !skel->hasBone(selected.toStdString())) + return {}; + Ogre::Bone* bone = skel->getBone(selected.toStdString()); + Ogre::Node* parent = bone ? bone->getParent() : nullptr; + if (!parent) return {}; + return QString::fromStdString(parent->getName()); +} + +QStringList SkeletonEditor::reparentCandidateParents() const +{ + QStringList out; + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return out; + Ogre::SkeletonPtr skel = skeletonResource(entity); + if (!skel) return out; + const QString selected = selectedBoneName(); + std::unordered_set blocked; + if (!selected.isEmpty() && skel->hasBone(selected.toStdString())) + blocked = collectDescendants(skel.get(), selected.toStdString()); + for (unsigned short i = 0; i < skel->getNumBones(); ++i) { + const std::string name = skel->getBone(i)->getName(); + if (blocked.count(name)) continue; + out << QString::fromStdString(name); + } + return out; +} + +QVariantList SkeletonEditor::attachTargetEntities() const +{ + QVariantList out; + Ogre::Entity* src = selectedSkinnedEntity(); + const std::string srcName = src ? src->getName() : std::string{}; + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) return out; + for (Ogre::Entity* ent : mgr->getEntities()) { + if (!ent || ent->getMovableType() != "Entity") continue; + if (ent->getName() == srcName) continue; + QVariantMap entry; + entry.insert(QStringLiteral("name"), QString::fromStdString(ent->getName())); + entry.insert(QStringLiteral("hasSkeleton"), ent->hasSkeleton()); + out.append(entry); + } + return out; +} + bool SkeletonEditor::createBoneForSelected(const QString& parentBoneName) { Ogre::Entity* entity = selectedSkinnedEntity(); @@ -746,3 +1259,80 @@ bool SkeletonEditor::duplicateSelectedBone() UndoManager::getSingleton()->push(cmd); return cmd->applied(); } + +bool SkeletonEditor::reparentSelectedBone(const QString& newParentName, bool keepWorld) +{ + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return false; + const QString bone = selectedBoneName(); + if (bone.isEmpty()) return false; + + ReparentOptions opts; + opts.keepWorld = keepWorld; + auto* cmd = new ReparentBoneCommand(entity->getName(), bone, newParentName, opts); + UndoManager::getSingleton()->push(cmd); + return cmd->applied(); +} + +bool SkeletonEditor::detachSelectedBone() +{ + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return false; + const QString bone = selectedBoneName(); + if (bone.isEmpty()) return false; + + ReparentOptions opts; + opts.keepWorld = true; + auto* cmd = new ReparentBoneCommand(entity->getName(), bone, {}, opts); + UndoManager::getSingleton()->push(cmd); + return cmd->applied(); +} + +bool SkeletonEditor::splitSelectedBone(float t) +{ + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return false; + const QString bone = selectedBoneName(); + if (bone.isEmpty()) return false; + + auto* cmd = new SplitBoneCommand(entity->getName(), bone, t); + UndoManager::getSingleton()->push(cmd); + return cmd->applied(); +} + +bool SkeletonEditor::setSelectedBoneConnected(bool connected) +{ + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return false; + const QString bone = selectedBoneName(); + if (bone.isEmpty()) return false; + + auto* cmd = new ConnectBoneCommand(entity->getName(), bone, connected); + UndoManager::getSingleton()->push(cmd); + return cmd->applied(); +} + +bool SkeletonEditor::isSelectedBoneConnected() const +{ + Ogre::Entity* entity = selectedSkinnedEntity(); + if (!entity) return false; + return isBoneConnected(entity, selectedBoneName()); +} + +bool SkeletonEditor::attachSelectedBoneToEntity(const QString& dstEntityName) +{ + Ogre::Entity* src = selectedSkinnedEntity(); + if (!src) return false; + const QString bone = selectedBoneName(); + if (bone.isEmpty() || dstEntityName.isEmpty()) return false; + + auto* cmd = new AttachBoneToEntityCommand( + src->getName(), QStringList{bone}, dstEntityName.toStdString(), {}); + UndoManager::getSingleton()->push(cmd); + return cmd->applied(); +} + +void SkeletonEditor::requestBoneContextMenu(int globalX, int globalY) +{ + emit boneContextMenuRequested(globalX, globalY); +} diff --git a/src/SkeletonEditor.h b/src/SkeletonEditor.h index a7bc88b16..8239876cc 100644 --- a/src/SkeletonEditor.h +++ b/src/SkeletonEditor.h @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -21,9 +23,8 @@ class Mesh; class Skeleton; } -/// Bone-level CRUD for skeletal rigs (epic #554, slice A #555). -/// Owns create / remove / rename / duplicate on the selected entity's -/// skeleton resource, with undo via SkeletonBoneCommands. +/// Bone-level CRUD + hierarchy editing for skeletal rigs +/// (epic #554: slice A #555 CRUD, slice B #556 hierarchy). class SkeletonEditor : public QObject { Q_OBJECT @@ -42,10 +43,19 @@ class SkeletonEditor : public QObject bool transferWeightsToParent = true; }; + struct ReparentOptions { + bool keepWorld = true; ///< false → keep local TRS unchanged + }; + + struct AttachOptions { + /// Reserved for a future geometry-transfer mode. Slice B is rig-only. + bool transferGeometry = false; + }; + struct Result { bool ok = false; QString error; - QString boneName; ///< created / duplicated bone name + QString boneName; ///< created / duplicated / split bone name }; /// Captured skeleton + mesh skin state for undo of destructive edits. @@ -107,6 +117,27 @@ class SkeletonEditor : public QObject static Result renameBone(Ogre::Entity* entity, const QString& oldName, const QString& newName); static Result duplicateBone(Ogre::Entity* entity, const QString& sourceBoneName); + /// Reparent `boneName` under `newParentName` (empty = detach to root). + static Result reparentBone(Ogre::Entity* entity, + const QString& boneName, + const QString& newParentName, + const ReparentOptions& opts); + /// Detach chain to root (keep-world). + static Result detachBone(Ogre::Entity* entity, const QString& boneName); + + /// Copy bone subtree(s) from src onto dst's skeleton (rig-only; no VBA copy). + static Result attachBonesToEntity(Ogre::Entity* srcEntity, + const QStringList& boneNames, + Ogre::Entity* dstEntity, + const AttachOptions& opts); + + /// Insert a bone at fraction `t` along the selected bone's axis (0 < t < 1). + static Result splitBone(Ogre::Entity* entity, const QString& boneName, float t = 0.5f); + + /// Snap / unsnap bone head to parent tip (Blender-style connect). + static Result setBoneConnected(Ogre::Entity* entity, const QString& boneName, bool connected); + static bool isBoneConnected(Ogre::Entity* entity, const QString& boneName); + static void refreshAfterEdit(const std::string& entityName, const QString& selectBone = {}); /// Push undo commands — used by QML and tests. @@ -114,9 +145,25 @@ class SkeletonEditor : public QObject Q_INVOKABLE bool removeSelectedBone(bool removeChildren, bool transferWeightsToParent); Q_INVOKABLE bool renameSelectedBone(const QString& newName); Q_INVOKABLE bool duplicateSelectedBone(); + Q_INVOKABLE bool reparentSelectedBone(const QString& newParentName, bool keepWorld = true); + Q_INVOKABLE bool detachSelectedBone(); + Q_INVOKABLE bool splitSelectedBone(float t = 0.5f); + Q_INVOKABLE bool setSelectedBoneConnected(bool connected); + Q_INVOKABLE bool isSelectedBoneConnected() const; + Q_INVOKABLE bool attachSelectedBoneToEntity(const QString& dstEntityName); Q_INVOKABLE bool hasSkeletonSelection() const; Q_INVOKABLE QString selectedBoneName() const; + /// Current parent of the selected bone; empty string if root / none. + Q_INVOKABLE QString selectedBoneParentName() const; + /// Candidate parents for the selected bone (excludes self + descendants). + Q_INVOKABLE QStringList reparentCandidateParents() const; + /// Other scene entities that can receive an attached bone. + Q_INVOKABLE QVariantList attachTargetEntities() const; + +/// Request the floating bone context menu at a global screen position + /// (viewport right-click or Inspector bone picker). + void requestBoneContextMenu(int globalX, int globalY); signals: void boneCreated(const QString& entityName, const QString& boneName); @@ -124,6 +171,7 @@ class SkeletonEditor : public QObject void boneRenamed(const QString& entityName, const QString& oldName, const QString& newName); void boneDuplicated(const QString& entityName, const QString& boneName); void skeletonStructureChanged(); + void boneContextMenuRequested(int globalX, int globalY); private: explicit SkeletonEditor(QObject* parent = nullptr); @@ -135,6 +183,7 @@ class SkeletonEditor : public QObject bool removeChildren, bool transferWeightsToParent, QString* error); + static bool ensureEntitySkeleton(Ogre::Entity* entity, QString* error); static SkeletonEditor* s_singleton; }; diff --git a/src/SkeletonEditor_test.cpp b/src/SkeletonEditor_test.cpp index daab7fc75..142f3902f 100644 --- a/src/SkeletonEditor_test.cpp +++ b/src/SkeletonEditor_test.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "SkeletonEditor.h" @@ -238,3 +239,185 @@ TEST_F(SkeletonEditorTest, DuplicateBoneRebindsAnimationControllerSkeleton) { EXPECT_TRUE(anim->boneNames().contains(result.boneName)); EXPECT_EQ(anim->selectedEntity()->getSkeleton(), entity->getSkeleton()); } + +TEST_F(SkeletonEditorTest, ReparentKeepWorldPreservesDerivedPosition) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_ReparentWorld"); + ASSERT_NE(entity, nullptr); + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + + Ogre::Bone* tip = skel->createBone("Tip", 2); + skel->getBone("Child")->addChild(tip); + tip->setPosition(Ogre::Vector3(0, 1, 0)); + tip->setInitialState(); + entity->_initialise(true); + + const Ogre::Vector3 worldBefore = tip->_getDerivedPosition(); + + SkeletonEditor::ReparentOptions opts; + opts.keepWorld = true; + // Reparent Tip under Root (skip Child) + const auto result = SkeletonEditor::reparentBone( + entity, QStringLiteral("Tip"), QStringLiteral("Root"), opts); + ASSERT_TRUE(result.ok) << result.error.toStdString(); + + skel = entity->getMesh()->getSkeleton(); + Ogre::Bone* tipAfter = skel->getBone("Tip"); + ASSERT_NE(tipAfter->getParent(), nullptr); + EXPECT_EQ(tipAfter->getParent()->getName(), "Root"); + EXPECT_NEAR(tipAfter->_getDerivedPosition().y, worldBefore.y, 1e-3f); +} + +TEST_F(SkeletonEditorTest, ReparentKeepLocalPreservesLocalTRS) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_ReparentLocal"); + ASSERT_NE(entity, nullptr); + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + + Ogre::Bone* tip = skel->createBone("Tip", 2); + skel->getBone("Child")->addChild(tip); + tip->setPosition(Ogre::Vector3(0.25f, 0.75f, 0)); + tip->setInitialState(); + entity->_initialise(true); + + const Ogre::Vector3 localBefore = tip->getPosition(); + + SkeletonEditor::ReparentOptions opts; + opts.keepWorld = false; + const auto result = SkeletonEditor::reparentBone( + entity, QStringLiteral("Tip"), QStringLiteral("Root"), opts); + ASSERT_TRUE(result.ok) << result.error.toStdString(); + + skel = entity->getMesh()->getSkeleton(); + Ogre::Bone* tipAfter = skel->getBone("Tip"); + EXPECT_NEAR(tipAfter->getPosition().x, localBefore.x, 1e-5f); + EXPECT_NEAR(tipAfter->getPosition().y, localBefore.y, 1e-5f); + EXPECT_NEAR(tipAfter->getPosition().z, localBefore.z, 1e-5f); +} + +TEST_F(SkeletonEditorTest, DetachMovesChainToRoot) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_Detach"); + ASSERT_NE(entity, nullptr); + + const auto result = SkeletonEditor::detachBone(entity, QStringLiteral("Child")); + ASSERT_TRUE(result.ok) << result.error.toStdString(); + + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + Ogre::Bone* child = skel->getBone("Child"); + EXPECT_EQ(child->getParent(), nullptr); + + // Weights still resolve to Child handle. + bool childWeighted = false; + for (const auto& kv : entity->getMesh()->getBoneAssignments()) { + if (kv.second.boneIndex == child->getHandle() && kv.second.weight > 0.f) { + childWeighted = true; + break; + } + } + EXPECT_TRUE(childWeighted); +} + +TEST_F(SkeletonEditorTest, ReparentRejectsCycle) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_Cycle"); + ASSERT_NE(entity, nullptr); + SkeletonEditor::ReparentOptions opts; + const auto result = SkeletonEditor::reparentBone( + entity, QStringLiteral("Root"), QStringLiteral("Child"), opts); + EXPECT_FALSE(result.ok); +} + +TEST_F(SkeletonEditorTest, SplitBoneInsertsChildAndRemapsWeights) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_Split"); + ASSERT_NE(entity, nullptr); + Ogre::Mesh* mesh = entity->getMesh().get(); + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + + Ogre::Bone* tip = skel->createBone("Tip", 2); + skel->getBone("Child")->addChild(tip); + tip->setPosition(Ogre::Vector3(0, 1, 0)); + tip->setInitialState(); + entity->_initialise(true); + + float weightSumBefore = 0.f; + for (const auto& kv : mesh->getBoneAssignments()) + weightSumBefore += kv.second.weight; + + const float t = 0.25f; + const auto result = SkeletonEditor::splitBone(entity, QStringLiteral("Child"), t); + ASSERT_TRUE(result.ok) << result.error.toStdString(); + + skel = entity->getMesh()->getSkeleton(); + ASSERT_TRUE(skel->hasBone(result.boneName.toStdString())); + Ogre::Bone* split = skel->getBone(result.boneName.toStdString()); + ASSERT_NE(split->getParent(), nullptr); + EXPECT_EQ(split->getParent()->getName(), "Child"); + // Split joint sits at fraction t along Child → Tip. + EXPECT_NEAR(split->getInitialPosition().y, t, 1e-4f); + ASSERT_TRUE(skel->hasBone("Tip")); + Ogre::Bone* tipAfter = skel->getBone("Tip"); + ASSERT_NE(tipAfter->getParent(), nullptr); + EXPECT_EQ(tipAfter->getParent()->getName(), result.boneName.toStdString()); + EXPECT_NEAR(tipAfter->getInitialPosition().y, 1.f - t, 1e-4f); + + float weightSumAfter = 0.f; + for (const auto& kv : mesh->getBoneAssignments()) + weightSumAfter += kv.second.weight; + EXPECT_NEAR(weightSumAfter, weightSumBefore, 1e-3f); +} + +TEST_F(SkeletonEditorTest, ConnectDisconnectTogglesHeadGap) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_Connect"); + ASSERT_NE(entity, nullptr); + + EXPECT_TRUE(SkeletonEditor::isBoneConnected(entity, QStringLiteral("Child"))); + + auto disc = SkeletonEditor::setBoneConnected(entity, QStringLiteral("Child"), false); + ASSERT_TRUE(disc.ok) << disc.error.toStdString(); + EXPECT_FALSE(SkeletonEditor::isBoneConnected(entity, QStringLiteral("Child"))); + + auto conn = SkeletonEditor::setBoneConnected(entity, QStringLiteral("Child"), true); + ASSERT_TRUE(conn.ok) << conn.error.toStdString(); + EXPECT_TRUE(SkeletonEditor::isBoneConnected(entity, QStringLiteral("Child"))); +} + +TEST_F(SkeletonEditorTest, AttachBoneToEntityCopiesRigOnly) { + Ogre::Entity* src = createAnimatedTestEntity("SkelEd_AttachSrc"); + Ogre::Entity* dst = createAnimatedTestEntity("SkelEd_AttachDst"); + ASSERT_NE(src, nullptr); + ASSERT_NE(dst, nullptr); + + // Force a name collision on destination. + ASSERT_TRUE(dst->getMesh()->getSkeleton()->hasBone("Child")); + + const auto result = SkeletonEditor::attachBonesToEntity( + src, QStringList{QStringLiteral("Child")}, dst, {}); + ASSERT_TRUE(result.ok) << result.error.toStdString(); + EXPECT_TRUE(result.boneName.startsWith(QStringLiteral("Child"))); + EXPECT_NE(result.boneName, QStringLiteral("Child")); + + Ogre::SkeletonPtr dstSkel = dst->getMesh()->getSkeleton(); + EXPECT_TRUE(dstSkel->hasBone(result.boneName.toStdString())); + EXPECT_EQ(dstSkel->getNumBones(), 3u); + // Source unchanged. + EXPECT_TRUE(src->getMesh()->getSkeleton()->hasBone("Child")); + EXPECT_EQ(src->getMesh()->getSkeleton()->getNumBones(), 2u); +} + +TEST_F(SkeletonEditorTest, ReparentAndSplitUndoViaCommand) { + Ogre::Entity* entity = createAnimatedTestEntity("SkelEd_HierUndo"); + ASSERT_NE(entity, nullptr); + + SkeletonEditor::ReparentOptions opts; + opts.keepWorld = true; + UndoManager::getSingleton()->push( + new ReparentBoneCommand(entity->getName(), QStringLiteral("Child"), {}, opts)); + EXPECT_EQ(entity->getMesh()->getSkeleton()->getBone("Child")->getParent(), nullptr); + + UndoManager::getSingleton()->undo(); + EXPECT_NE(entity->getMesh()->getSkeleton()->getBone("Child")->getParent(), nullptr); + + UndoManager::getSingleton()->push( + new SplitBoneCommand(entity->getName(), QStringLiteral("Child"), 0.5f)); + EXPECT_EQ(entity->getMesh()->getSkeleton()->getNumBones(), 3u); + + UndoManager::getSingleton()->undo(); + EXPECT_EQ(entity->getMesh()->getSkeleton()->getNumBones(), 2u); +} diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index ac3e2225a..d547ff0ab 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -33,6 +33,7 @@ #include "AnimationControlController.h" #include "PropertiesPanelController.h" #include "SkeletonDebug.h" +#include "SkeletonEditor.h" #include // TODO create a virtual class GizmoObject & add Rotation & Translation Gizmo to have only one interface @@ -1101,6 +1102,21 @@ void TransformOperator::performBoxSelection(const QPoint& first, const QPoint& s void TransformOperator::mousePressEvent(QMouseEvent *e) { + // Right-click on a visible skeleton bone → pick it and open the floating + // hierarchy context menu (Inspector-styled). + if (e->button() == Qt::RightButton) + { + if (tryPickBoneAt(e->pos())) + { + if (m_pActiveWidget) { + const QPoint global = m_pActiveWidget->mapToGlobal(e->pos()); + if (auto* skelEd = SkeletonEditor::getSingletonPtr()) + skelEd->requestBoneContextMenu(global.x(), global.y()); + } + return; + } + } + if (e->button()==Qt::LeftButton) { // Auto-rig marker placement (Mixamo-style) is active: left-click drops diff --git a/src/commands/SkeletonBoneCommands.cpp b/src/commands/SkeletonBoneCommands.cpp index ce230867e..83af043bb 100644 --- a/src/commands/SkeletonBoneCommands.cpp +++ b/src/commands/SkeletonBoneCommands.cpp @@ -249,3 +249,188 @@ void ToggleSkeletonDebugCommand::undo() { apply(!m_show); } + +ReparentBoneCommand::ReparentBoneCommand(std::string entityName, + QString boneName, + QString newParentName, + SkeletonEditor::ReparentOptions opts, + QUndoCommand* parent) + : QUndoCommand(parent) + , m_entityName(std::move(entityName)) + , m_boneName(std::move(boneName)) + , m_newParentName(std::move(newParentName)) + , m_opts(opts) +{ + setText(m_newParentName.isEmpty() + ? QStringLiteral("Detach bone") + : QStringLiteral("Reparent bone")); +} + +void ReparentBoneCommand::redo() +{ + Ogre::Entity* entity = resolveEntityByName(m_entityName); + if (!entity) return; + if (m_firstRedo) { + m_before = SkeletonEditor::captureSnapshot(entity); + m_firstRedo = false; + } + const auto result = SkeletonEditor::reparentBone(entity, m_boneName, m_newParentName, m_opts); + if (!result.ok) return; + m_applied = true; + SentryReporter::addBreadcrumb( + m_newParentName.isEmpty() + ? QStringLiteral("scene.skel.hier.detach") + : QStringLiteral("scene.skel.hier.reparent"), + QStringLiteral("%1: %2 → %3") + .arg(QString::fromStdString(m_entityName), m_boneName, + m_newParentName.isEmpty() ? QStringLiteral("(root)") : m_newParentName)); + SkeletonEditor::refreshAfterEdit(m_entityName, m_boneName); +} + +void ReparentBoneCommand::undo() +{ + Ogre::Entity* entity = resolveEntityByName(m_entityName); + if (!entity) return; + QString err; + if (SkeletonEditor::restoreSnapshot(entity, m_before, &err)) + SkeletonEditor::refreshAfterEdit(m_entityName, m_boneName); + m_applied = false; +} + +SplitBoneCommand::SplitBoneCommand(std::string entityName, + QString boneName, + float t, + QUndoCommand* parent) + : QUndoCommand(parent) + , m_entityName(std::move(entityName)) + , m_boneName(std::move(boneName)) + , m_t(t) +{ + setText(QStringLiteral("Split bone")); +} + +void SplitBoneCommand::redo() +{ + Ogre::Entity* entity = resolveEntityByName(m_entityName); + if (!entity) return; + if (m_firstRedo) { + m_before = SkeletonEditor::captureSnapshot(entity); + m_firstRedo = false; + } + const auto result = SkeletonEditor::splitBone(entity, m_boneName, m_t); + if (!result.ok) return; + m_splitBoneName = result.boneName; + m_applied = true; + SentryReporter::addBreadcrumb(QStringLiteral("scene.skel.hier.split"), + QStringLiteral("%1: %2 @%3 → %4") + .arg(QString::fromStdString(m_entityName), m_boneName) + .arg(m_t, 0, 'f', 2) + .arg(m_splitBoneName)); + SkeletonEditor::refreshAfterEdit(m_entityName, m_splitBoneName); +} + +void SplitBoneCommand::undo() +{ + Ogre::Entity* entity = resolveEntityByName(m_entityName); + if (!entity) return; + QString err; + if (SkeletonEditor::restoreSnapshot(entity, m_before, &err)) + SkeletonEditor::refreshAfterEdit(m_entityName, m_boneName); + m_applied = false; +} + +ConnectBoneCommand::ConnectBoneCommand(std::string entityName, + QString boneName, + bool connected, + QUndoCommand* parent) + : QUndoCommand(parent) + , m_entityName(std::move(entityName)) + , m_boneName(std::move(boneName)) + , m_connected(connected) +{ + setText(connected ? QStringLiteral("Connect bone") : QStringLiteral("Disconnect bone")); +} + +void ConnectBoneCommand::redo() +{ + Ogre::Entity* entity = resolveEntityByName(m_entityName); + if (!entity) return; + if (m_firstRedo) { + m_before = SkeletonEditor::captureSnapshot(entity); + m_firstRedo = false; + } + const auto result = SkeletonEditor::setBoneConnected(entity, m_boneName, m_connected); + if (!result.ok) return; + m_applied = true; + SentryReporter::addBreadcrumb(QStringLiteral("scene.skel.hier.connect"), + QStringLiteral("%1: %2 %3") + .arg(QString::fromStdString(m_entityName), m_boneName, + m_connected ? QStringLiteral("connect") : QStringLiteral("disconnect"))); + SkeletonEditor::refreshAfterEdit(m_entityName, m_boneName); +} + +void ConnectBoneCommand::undo() +{ + Ogre::Entity* entity = resolveEntityByName(m_entityName); + if (!entity) return; + QString err; + if (SkeletonEditor::restoreSnapshot(entity, m_before, &err)) + SkeletonEditor::refreshAfterEdit(m_entityName, m_boneName); + m_applied = false; +} + +AttachBoneToEntityCommand::AttachBoneToEntityCommand(std::string srcEntityName, + QStringList boneNames, + std::string dstEntityName, + SkeletonEditor::AttachOptions opts, + QUndoCommand* parent) + : QUndoCommand(parent) + , m_srcEntityName(std::move(srcEntityName)) + , m_boneNames(std::move(boneNames)) + , m_dstEntityName(std::move(dstEntityName)) + , m_opts(opts) +{ + setText(QStringLiteral("Attach bone to entity")); +} + +void AttachBoneToEntityCommand::redo() +{ + Ogre::Entity* src = resolveEntityByName(m_srcEntityName); + Ogre::Entity* dst = resolveEntityByName(m_dstEntityName); + if (!src || !dst) return; + if (m_firstRedo) { + if (dst->getMesh() && dst->getMesh()->hasSkeleton() && dst->getMesh()->getSkeleton()) + m_dstBefore = SkeletonEditor::captureSnapshot(dst); + m_firstRedo = false; + } + + const auto result = SkeletonEditor::attachBonesToEntity(src, m_boneNames, dst, m_opts); + if (!result.ok) return; + m_attachedBoneName = result.boneName; + m_applied = true; + SentryReporter::addBreadcrumb(QStringLiteral("scene.skel.hier.attach"), + QStringLiteral("%1 → %2: %3") + .arg(QString::fromStdString(m_srcEntityName), + QString::fromStdString(m_dstEntityName), + m_attachedBoneName)); + SkeletonEditor::refreshAfterEdit(m_dstEntityName, m_attachedBoneName); +} + +void AttachBoneToEntityCommand::undo() +{ + Ogre::Entity* dst = resolveEntityByName(m_dstEntityName); + if (!dst) return; + if (m_dstBefore.bones.empty()) { + // Destination had no skeleton before attach — strip skeleton binding. + if (dst->getMesh()) { + dst->getMesh()->_notifySkeleton(Ogre::SkeletonPtr()); + dst->_initialise(true); + } + SkeletonEditor::refreshAfterEdit(m_dstEntityName); + } else { + QString err; + if (SkeletonEditor::restoreSnapshot(dst, m_dstBefore, &err)) + SkeletonEditor::refreshAfterEdit(m_dstEntityName); + } + m_applied = false; +} diff --git a/src/commands/SkeletonBoneCommands.h b/src/commands/SkeletonBoneCommands.h index 1b2e0e01b..535a2c41e 100644 --- a/src/commands/SkeletonBoneCommands.h +++ b/src/commands/SkeletonBoneCommands.h @@ -106,3 +106,99 @@ class ToggleSkeletonDebugCommand : public QUndoCommand QString m_entityName; bool m_show = false; }; + +class ReparentBoneCommand : public QUndoCommand +{ +public: + ReparentBoneCommand(std::string entityName, + QString boneName, + QString newParentName, + SkeletonEditor::ReparentOptions opts, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + bool applied() const { return m_applied; } + +private: + std::string m_entityName; + QString m_boneName; + QString m_newParentName; + SkeletonEditor::ReparentOptions m_opts; + SkeletonEditor::Snapshot m_before; + bool m_applied = false; + bool m_firstRedo = true; +}; + +class SplitBoneCommand : public QUndoCommand +{ +public: + SplitBoneCommand(std::string entityName, + QString boneName, + float t, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + bool applied() const { return m_applied; } + const QString& splitBoneName() const { return m_splitBoneName; } + +private: + std::string m_entityName; + QString m_boneName; + float m_t = 0.5f; + QString m_splitBoneName; + SkeletonEditor::Snapshot m_before; + bool m_applied = false; + bool m_firstRedo = true; +}; + +class ConnectBoneCommand : public QUndoCommand +{ +public: + ConnectBoneCommand(std::string entityName, + QString boneName, + bool connected, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + bool applied() const { return m_applied; } + +private: + std::string m_entityName; + QString m_boneName; + bool m_connected = true; + SkeletonEditor::Snapshot m_before; + bool m_applied = false; + bool m_firstRedo = true; +}; + +class AttachBoneToEntityCommand : public QUndoCommand +{ +public: + AttachBoneToEntityCommand(std::string srcEntityName, + QStringList boneNames, + std::string dstEntityName, + SkeletonEditor::AttachOptions opts, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + bool applied() const { return m_applied; } + const QString& attachedBoneName() const { return m_attachedBoneName; } + +private: + std::string m_srcEntityName; + QStringList m_boneNames; + std::string m_dstEntityName; + SkeletonEditor::AttachOptions m_opts; + SkeletonEditor::Snapshot m_dstBefore; + QString m_attachedBoneName; + bool m_applied = false; + bool m_firstRedo = true; +}; diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index fdf7a15da..46ca92d4f 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -14,6 +14,10 @@ ../qml/SkinWeightsDialog.qml ../qml/RemoveBoneDialog.qml ../qml/RenameBoneDialog.qml + ../qml/ReparentBoneDialog.qml + ../qml/SplitBoneDialog.qml + ../qml/AttachBoneDialog.qml + ../qml/BoneContextMenu.qml ../qml/IsometricSpritesDialog.qml ../qml/qmldir ../qml/ThemedButton.qml