From c3a00386e4e7f031409e9d8cdf8c53a235c8a759 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 2 Sep 2026 00:33:36 -0500 Subject: [PATCH 01/16] Initial Bust Tracking --- .../slimevr/skeleton/bodypart-structure.kt | 5 +++ .../bust-direct-link-input-processor.kt | 31 +++++++++++++++ .../main/java/dev/slimevr/skeleton/module.kt | 2 + .../src/main/java/dev/slimevr/vmc/mapping.kt | 7 ++++ .../BustDirectLinkInputProcessorTest.kt | 39 +++++++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt create mode 100644 server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index 01c154acd9..3d3d7815e8 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -79,6 +79,11 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_RING_TOE, BodyPart.RIGHT_LITTLE_TOE, ), + + BodyPart.CHEST to arrayOf( + BodyPart.LEFT_BUST, + BodyPart.RIGHT_BUST + ) ), ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt new file mode 100644 index 0000000000..88bcc36bce --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt @@ -0,0 +1,31 @@ +package dev.slimevr.skeleton.inputprocessors + +import dev.slimevr.skeleton.InputSkeleton +import dev.slimevr.skeleton.SkeletonInputProcessor +import dev.slimevr.skeleton.mutateCopy +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles rotations of inactive toe bones. + */ +class BustDirectLinkInputProcessor : SkeletonInputProcessor { + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val bustToSource = arrayOf( + BodyPart.LEFT_BUST to BodyPart.CHEST, + BodyPart.RIGHT_BUST to BodyPart.CHEST, + ) + + override fun process(inputSkeleton: InputSkeleton, skeletonHeight: Float): InputSkeleton = inputSkeleton.mutateCopy { updated -> + for ((bodyPart, source) in bustToSource) { + val bone = updated[bodyPart] ?: continue + if (bone.isRotationActive) continue + val sourceBone = updated[source] + updated[bodyPart] = + bone.copy(rotation = sourceBone?.rotation ?: bone.rotation) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 19ed2ad5ae..db4c80af69 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -16,6 +16,7 @@ import dev.slimevr.skeleton.inputprocessors.HeadPositionFallbackProcessor import dev.slimevr.skeleton.inputprocessors.HipYawRollAlignInputProcessor import dev.slimevr.skeleton.inputprocessors.SpineImputeInputProcessor import dev.slimevr.skeleton.inputprocessors.ToeDirectLinkInputProcessor +import dev.slimevr.skeleton.inputprocessors.BustDirectLinkInputProcessor import dev.slimevr.skeleton.inputprocessors.UpperLegsRollAlignInputProcessor import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -182,6 +183,7 @@ class Skeleton( BoneDirectLinkInputProcessor(), FingerImputeInputProcessor(), ToeDirectLinkInputProcessor(), + BustDirectLinkInputProcessor(), BonePredictionInputProcessor(settings), BoneSmoothingInputProcessor(settings), ), diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index afcd9cd141..b81a615657 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -70,6 +70,8 @@ val BODY_PART_TO_UNITY_BONE: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_MIDDLE_TOE to arrayOf("RightMiddleToe"), BodyPart.RIGHT_RING_TOE to arrayOf("RightRingToe"), BodyPart.RIGHT_LITTLE_TOE to arrayOf("RightLittleToe"), + BodyPart.LEFT_BUST to arrayOf("LeftBust"), + BodyPart.RIGHT_BUST to arrayOf("RightBust"), ), ) @@ -143,6 +145,10 @@ val VMC_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_RING_TOE, BodyPart.RIGHT_LITTLE_TOE, ), + BodyPart.CHEST to arrayOf( + BodyPart.LEFT_BUST, + BodyPart.RIGHT_BUST + ), ), ) @@ -185,6 +191,7 @@ val VMC_MIRROR_BONE_PAIRS: List> = listOf( BodyPart.LEFT_MIDDLE_TOE to BodyPart.RIGHT_MIDDLE_TOE, BodyPart.LEFT_RING_TOE to BodyPart.RIGHT_RING_TOE, BodyPart.LEFT_LITTLE_TOE to BodyPart.RIGHT_LITTLE_TOE, + BodyPart.LEFT_BUST to BodyPart.RIGHT_BUST, ) val VMC_MIRROR_BONES: BodyPartMap = BodyPartMap( diff --git a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt new file mode 100644 index 0000000000..ca1a55e317 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt @@ -0,0 +1,39 @@ +package dev.slimevr.skeleton + +import dev.slimevr.skeleton.inputprocessors.ToeDirectLinkInputProcessor +import io.github.axisangles.ktmath.Quaternion +import org.junit.jupiter.api.Test +import solarxr_protocol.datatypes.BodyPart +import kotlin.test.assertTrue + +class BustDirectLinkInputProcessorTest { + @Test + fun `test all missing bust trackers`() { + val processor = ToeDirectLinkInputProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutateCopy { map -> + map[BodyPart.CHEST] = map.getValue(BodyPart.CHEST).copy( + rotation = Quaternion.fromRotationVector(10f, 40f, 15f), + isRotationActive = true, + ) + } + + val state = SkeletonState( + boneInputs = inputs, + skeletonHeight = 1.7f, + floorLevel = 0f, + paused = false, + pausedProcessedBoneInputs = inputs, + ) + + val newInputs = processor.process(state.boneInputs, state.skeletonHeight) + + val leftBustIsSameRotationAsChest = + newInputs[BodyPart.LEFT_BUST]?.rotation == newInputs[BodyPart.CHEST]?.rotation + + val rightBustIsSameRotationAsChest = + newInputs[BodyPart.RIGHT_BUST]?.rotation == newInputs[BodyPart.CHEST]?.rotation + + assertTrue(leftBustIsSameRotationAsChest) + assertTrue(rightBustIsSameRotationAsChest) + } +} From 8a9b2047b8993a4af6dba2bb570a4e12bda8d1a4 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 2 Sep 2026 22:01:49 -0500 Subject: [PATCH 02/16] Refinement attempts. --- bust-plugin-output-encoder-fix.kt | 72 +++++++++++++++++++ .../java/dev/slimevr/resets/bodypart-sets.kt | 5 +- .../bust-direct-link-input-processor.kt | 2 +- .../vrcosc/bust-plugin-output-encoder.kt | 72 +++++++++++++++++++ .../java/dev/slimevr/vrcosc/output-encoder.kt | 3 +- 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 bust-plugin-output-encoder-fix.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt diff --git a/bust-plugin-output-encoder-fix.kt b/bust-plugin-output-encoder-fix.kt new file mode 100644 index 0000000000..34c1c0018d --- /dev/null +++ b/bust-plugin-output-encoder-fix.kt @@ -0,0 +1,72 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.skeleton.BoneState +import dev.slimevr.util.Side +import io.github.axisangles.ktmath.EulerOrder +import solarxr_protocol.datatypes.BodyPart + +private const val MAXIMUM_ABSOLUTE_BUST_RANGE = 90 + +internal fun buildBustMessages(bones: Map): List { + val messages = mutableListOf() + + // Handle case where chest bone is missing + val chest = bones[BodyPart.CHEST] + + // Process left bust only if chest exists + if(chest != null) { + val leftBust = bones[BodyPart.LEFT_BUST] + processBust(chest, leftBust, Side.LEFT, messages) + + // Process right bust only if chest exists + val rightBust = bones[BodyPart.RIGHT_BUST] + processBust(chest, rightBust, Side.RIGHT, messages) + } + + return messages +} + +private fun processBust( + chest: BoneState?, + bust: BoneState?, + side: Side, + messages: MutableList, +) { + if(bust == null) return + + // Guard against null chest + if(chest == null) return + + val bustRot = bust.rotation + val currentRelative = chest.rotation.inv() * bustRot + val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) + val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() + val yaw = Math.toDegrees(euler.z.toDouble()).toFloat() + val bustPitch = (pitch / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) + val bustYaw = (yaw / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) + messages.addAll( + listOf( + OscContent.Message( + OscMessage( + "/avatar/parameters/${side.oscName}Bust", + listOf(OscArg.Float(bustPitch)) + ) + ), + OscContent.Message( + OscMessage( + "/avatar/parameters/${side.oscName}Bust", + listOf(OscArg.Float(bustYaw)) + ) + ), + ), + ) +} + +private val Side.oscName: String + get() = when (this) { + Side.LEFT -> "Left" + Side.RIGHT -> "Right" + } \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt index b1efd44b00..bb4f947c8c 100644 --- a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt +++ b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt @@ -22,7 +22,10 @@ object ResetBodyParts { BodyPart.RIGHT_RING_TOE, BodyPart.RIGHT_LITTLE_TOE, ) - + val BUST = setOf( + BodyPart.LEFT_BUST, + BodyPart.RIGHT_BUST + ) val FEET = setOf( BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt index 88bcc36bce..8599f60c50 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt @@ -6,7 +6,7 @@ import dev.slimevr.skeleton.mutateCopy import solarxr_protocol.datatypes.BodyPart /** - * Handles rotations of inactive toe bones. + * Handles rotations of inactive bust bones. */ class BustDirectLinkInputProcessor : SkeletonInputProcessor { /** diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt new file mode 100644 index 0000000000..be5b49233b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt @@ -0,0 +1,72 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.skeleton.BoneState +import dev.slimevr.util.Side +import io.github.axisangles.ktmath.EulerOrder +import solarxr_protocol.datatypes.BodyPart + +private const val MAXIMUM_ABSOLUTE_BUST_RANGE = 90 + +internal fun buildBustMessages(bones: Map): List { + val messages = mutableListOf() + + // Handle case where chest bone is missing + val chest = bones[BodyPart.CHEST] + + // Process left bust only if chest exists + if(chest != null) { + val leftBust = bones[BodyPart.LEFT_BUST] + processBust(chest, leftBust, Side.LEFT, messages) + + // Process right bust only if chest exists + val rightBust = bones[BodyPart.RIGHT_BUST] + processBust(chest, rightBust, Side.RIGHT, messages) + } + + return messages +} + +private fun processBust( + chest: BoneState?, + bust: BoneState?, + side: Side, + messages: MutableList, +) { + if(bust == null) return + + // Guard against null chest + if(chest == null) return + + val bustRot = bust.rotation + val currentRelative = chest.rotation.inv() * bustRot + val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) + val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() + val yaw = Math.toDegrees(euler.z.toDouble()).toFloat() + val bustPitch = (pitch / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) + val bustYaw = (yaw / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) + messages.addAll( + listOf( + OscContent.Message( + OscMessage( + "/avatar/parameters/${side.oscName}Bust", + listOf(OscArg.Float(bustPitch)) + ) + ), + OscContent.Message( + OscMessage( + "/avatar/parameters/${side.oscName}Bust", + listOf(OscArg.Float(bustYaw)) + ) + ), + ), + ) +} + +private val Side.oscName: String + get() = when (this) { + Side.LEFT -> "Left" + Side.RIGHT -> "Right" + } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index baa5ac49f0..c2a2f85395 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -54,8 +54,9 @@ internal fun buildOutgoingBundle( ), ) } - + addAll(buildToeMessages(bones)) + addAll(buildBustMessages(bones)) } return messages.takeIf { it.isNotEmpty() }?.let { OscBundle(1L, it) } From 3d007bc2105c6cd8e860b476501580d61c0ead3f Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 2 Sep 2026 22:42:57 -0500 Subject: [PATCH 03/16] Additional tweaks and refactor. --- .../slimevr/skeleton/bodypart-structure.kt | 10 +++--- .../inputprocessors/bone-direct-link.kt | 3 ++ .../bust-direct-link-input-processor.kt | 31 ------------------- .../vrcosc/bust-plugin-output-encoder.kt | 14 +++------ 4 files changed, 12 insertions(+), 46 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index 3d3d7815e8..839c5879fc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -52,7 +52,10 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST), - BodyPart.CHEST to arrayOf(BodyPart.WAIST), + BodyPart.CHEST to arrayOf( + BodyPart.WAIST, + BodyPart.LEFT_BUST, + BodyPart.RIGHT_BUST), BodyPart.WAIST to arrayOf(BodyPart.HIP), BodyPart.HIP to arrayOf(BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP), @@ -79,11 +82,6 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_RING_TOE, BodyPart.RIGHT_LITTLE_TOE, ), - - BodyPart.CHEST to arrayOf( - BodyPart.LEFT_BUST, - BodyPart.RIGHT_BUST - ) ), ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt index 1abfbd4ac7..368a0d43ba 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt @@ -46,6 +46,9 @@ class BoneDirectLinkInputProcessor : SkeletonInputProcessor { BodyPart.RIGHT_MIDDLE_TOE to BodyPart.RIGHT_INDEX_TOE, BodyPart.RIGHT_RING_TOE to BodyPart.RIGHT_MIDDLE_TOE, BodyPart.RIGHT_LITTLE_TOE to BodyPart.RIGHT_RING_TOE, + + BodyPart.LEFT_BUST to BodyPart.CHEST, + BodyPart.RIGHT_BUST to BodyPart.CHEST ) override fun process(mutableInputSkeleton: InputSkeleton, skeletonHeight: Float) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt deleted file mode 100644 index 8599f60c50..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-direct-link-input-processor.kt +++ /dev/null @@ -1,31 +0,0 @@ -package dev.slimevr.skeleton.inputprocessors - -import dev.slimevr.skeleton.InputSkeleton -import dev.slimevr.skeleton.SkeletonInputProcessor -import dev.slimevr.skeleton.mutateCopy -import solarxr_protocol.datatypes.BodyPart - -/** - * Handles rotations of inactive bust bones. - */ -class BustDirectLinkInputProcessor : SkeletonInputProcessor { - /** - * First element is the linked BodyPart. - * - * Second element is the BodyPart the first element is linked to. - */ - private val bustToSource = arrayOf( - BodyPart.LEFT_BUST to BodyPart.CHEST, - BodyPart.RIGHT_BUST to BodyPart.CHEST, - ) - - override fun process(inputSkeleton: InputSkeleton, skeletonHeight: Float): InputSkeleton = inputSkeleton.mutateCopy { updated -> - for ((bodyPart, source) in bustToSource) { - val bone = updated[bodyPart] ?: continue - if (bone.isRotationActive) continue - val sourceBone = updated[source] - updated[bodyPart] = - bone.copy(rotation = sourceBone?.rotation ?: bone.rotation) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt index be5b49233b..de5ea0d963 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt @@ -12,20 +12,16 @@ private const val MAXIMUM_ABSOLUTE_BUST_RANGE = 90 internal fun buildBustMessages(bones: Map): List { val messages = mutableListOf() - - // Handle case where chest bone is missing val chest = bones[BodyPart.CHEST] - - // Process left bust only if chest exists + if(chest != null) { val leftBust = bones[BodyPart.LEFT_BUST] processBust(chest, leftBust, Side.LEFT, messages) - - // Process right bust only if chest exists + val rightBust = bones[BodyPart.RIGHT_BUST] processBust(chest, rightBust, Side.RIGHT, messages) } - + return messages } @@ -36,10 +32,10 @@ private fun processBust( messages: MutableList, ) { if(bust == null) return - + // Guard against null chest if(chest == null) return - + val bustRot = bust.rotation val currentRelative = chest.rotation.inv() * bustRot val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) From f2e95d332233425a3a9d7a3a824d9d00e231a7ad Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 2 Sep 2026 23:11:49 -0500 Subject: [PATCH 04/16] Make sure we actually add the bust to proportions. --- bust-plugin-output-encoder-fix.kt | 72 ------------------- .../slimevr/skeleton/bodypart-structure.kt | 4 +- .../java/dev/slimevr/skeleton/proportions.kt | 12 ++++ .../vrcosc/bust-plugin-output-encoder.kt | 4 +- 4 files changed, 16 insertions(+), 76 deletions(-) delete mode 100644 bust-plugin-output-encoder-fix.kt diff --git a/bust-plugin-output-encoder-fix.kt b/bust-plugin-output-encoder-fix.kt deleted file mode 100644 index 34c1c0018d..0000000000 --- a/bust-plugin-output-encoder-fix.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.slimevr.vrcosc - -import dev.slimevr.osc.OscArg -import dev.slimevr.osc.OscContent -import dev.slimevr.osc.OscMessage -import dev.slimevr.skeleton.BoneState -import dev.slimevr.util.Side -import io.github.axisangles.ktmath.EulerOrder -import solarxr_protocol.datatypes.BodyPart - -private const val MAXIMUM_ABSOLUTE_BUST_RANGE = 90 - -internal fun buildBustMessages(bones: Map): List { - val messages = mutableListOf() - - // Handle case where chest bone is missing - val chest = bones[BodyPart.CHEST] - - // Process left bust only if chest exists - if(chest != null) { - val leftBust = bones[BodyPart.LEFT_BUST] - processBust(chest, leftBust, Side.LEFT, messages) - - // Process right bust only if chest exists - val rightBust = bones[BodyPart.RIGHT_BUST] - processBust(chest, rightBust, Side.RIGHT, messages) - } - - return messages -} - -private fun processBust( - chest: BoneState?, - bust: BoneState?, - side: Side, - messages: MutableList, -) { - if(bust == null) return - - // Guard against null chest - if(chest == null) return - - val bustRot = bust.rotation - val currentRelative = chest.rotation.inv() * bustRot - val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) - val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() - val yaw = Math.toDegrees(euler.z.toDouble()).toFloat() - val bustPitch = (pitch / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) - val bustYaw = (yaw / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) - messages.addAll( - listOf( - OscContent.Message( - OscMessage( - "/avatar/parameters/${side.oscName}Bust", - listOf(OscArg.Float(bustPitch)) - ) - ), - OscContent.Message( - OscMessage( - "/avatar/parameters/${side.oscName}Bust", - listOf(OscArg.Float(bustYaw)) - ) - ), - ), - ) -} - -private val Side.oscName: String - get() = when (this) { - Side.LEFT -> "Left" - Side.RIGHT -> "Right" - } \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index 839c5879fc..608715853e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -53,9 +53,9 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST), BodyPart.CHEST to arrayOf( - BodyPart.WAIST, BodyPart.LEFT_BUST, - BodyPart.RIGHT_BUST), + BodyPart.RIGHT_BUST, + BodyPart.WAIST), BodyPart.WAIST to arrayOf(BodyPart.HIP), BodyPart.HIP to arrayOf(BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 05700ac9ad..c1ba359b07 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -120,6 +120,8 @@ fun Map.toBoneOffsets(): BodyPartMap { this[SkeletonBone.HAND_Y]?.let { offsets.putAll(getFingerOffsets(it)) } // Toes this[SkeletonBone.FOOT_LENGTH]?.let { offsets.putAll(getToeOffsets(it)) } + // Bust + this[SkeletonBone.CHEST]?.let { offsets.putAll(getBustOffsets(it)) } return offsets } @@ -218,3 +220,13 @@ private fun getToeOffsets(footLength: Float) = ( ).map { it.second }.associateWith { Vector3(0f, 0f, -footLength * 0.2f) } + +/** + * Returns the offsets for the bust bones scaled from the chestLength. + */ +private fun getBustOffsets(bustLength: Float) = ( + iterateBodyPartHierarchy(BodyPart.LEFT_BUST, true) + + iterateBodyPartHierarchy(BodyPart.RIGHT_BUST, true) + ).map { it.second }.associateWith { + Vector3(0f, 0f, bustLength * 0.2f) + } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt index de5ea0d963..dc7e7cc2fa 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt @@ -47,13 +47,13 @@ private fun processBust( listOf( OscContent.Message( OscMessage( - "/avatar/parameters/${side.oscName}Bust", + "/avatar/parameters/${side.oscName}BustPitch", listOf(OscArg.Float(bustPitch)) ) ), OscContent.Message( OscMessage( - "/avatar/parameters/${side.oscName}Bust", + "/avatar/parameters/${side.oscName}BustYaw", listOf(OscArg.Float(bustYaw)) ) ), From 075a8ab1a6be5af7755cf27b20c251ba7fbf7e12 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Thu, 3 Sep 2026 00:37:54 -0500 Subject: [PATCH 05/16] Rough bust preview. --- .../widgets/SkeletonVisualizerWidget.tsx | 3 +- gui/app/src/utils/skeletonHelper.ts | 17 ++++++++-- gui/app/src/utils/skeletonMeshHelper.ts | 2 +- gui/app/src/utils/skeletonParts.ts | 21 +++++++++++- .../slimevr/skeleton/bodypart-structure.kt | 6 ++-- .../inputprocessors/bone-direct-link.kt | 4 +-- .../inputprocessors/bust-input-processor.kt | 33 +++++++++++++++++++ .../main/java/dev/slimevr/skeleton/module.kt | 2 ++ .../java/dev/slimevr/skeleton/proportions.kt | 13 ++++---- .../src/main/java/dev/slimevr/vmc/mapping.kt | 2 +- .../vrcosc/bust-plugin-output-encoder.kt | 2 +- .../BustDirectLinkInputProcessorTest.kt | 9 ++--- 12 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt diff --git a/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx b/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx index d1ebe2fac5..4372efc6ce 100644 --- a/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx +++ b/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx @@ -1,8 +1,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; import { useMemo, useEffect, useState, useRef, useLayoutEffect } from 'react'; -import { - BoneKind, +import BoneKind, { createChildren, BasedSkeletonHelper, } from '@/utils/skeletonHelper'; diff --git a/gui/app/src/utils/skeletonHelper.ts b/gui/app/src/utils/skeletonHelper.ts index 5518563a2e..3201e784ea 100644 --- a/gui/app/src/utils/skeletonHelper.ts +++ b/gui/app/src/utils/skeletonHelper.ts @@ -125,7 +125,7 @@ export function getBoneList(object: Object3D): Bone[] { return boneList; } -export class BoneKind extends Bone { +class BoneKind extends Bone { boneT: BoneT; tail: boolean; @@ -182,6 +182,10 @@ export class BoneKind extends Bone { return new Color('purple'); case BodyPart.WAIST: return new Color('red'); + case BodyPart.LEFT_BUST: + return new Color('green'); + case BodyPart.RIGHT_BUST: + return new Color('green'); case BodyPart.HIP: return new Color('orange'); case BodyPart.LEFT_UPPER_LEG: @@ -264,7 +268,7 @@ export class BoneKind extends Bone { case BodyPart.NECK: return [BodyPart.UPPER_CHEST, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER]; case BodyPart.UPPER_CHEST: - return [BodyPart.CHEST]; + return [BodyPart.LEFT_BUST, BodyPart.RIGHT_BUST, BodyPart.CHEST]; case BodyPart.CHEST: return [BodyPart.WAIST]; case BodyPart.WAIST: @@ -389,7 +393,6 @@ export class BoneKind extends Bone { return [BodyPart.RIGHT_LITTLE_DISTAL]; case BodyPart.RIGHT_LITTLE_DISTAL: return []; - return []; case BodyPart.LEFT_BIG_TOE: case BodyPart.LEFT_INDEX_TOE: case BodyPart.LEFT_MIDDLE_TOE: @@ -400,6 +403,8 @@ export class BoneKind extends Bone { case BodyPart.RIGHT_MIDDLE_TOE: case BodyPart.RIGHT_RING_TOE: case BodyPart.RIGHT_LITTLE_TOE: + case BodyPart.LEFT_BUST: + case BodyPart.RIGHT_BUST: return []; } } @@ -418,6 +423,10 @@ export class BoneKind extends Bone { return BodyPart.UPPER_CHEST; case BodyPart.WAIST: return BodyPart.CHEST; + case BodyPart.LEFT_BUST: + return BodyPart.UPPER_CHEST; + case BodyPart.RIGHT_BUST: + return BodyPart.UPPER_CHEST; case BodyPart.HIP: return BodyPart.WAIST; @@ -538,6 +547,8 @@ export class BoneKind extends Bone { } } +export default BoneKind; + export function createChildren( bones: Map, body: BodyPart, diff --git a/gui/app/src/utils/skeletonMeshHelper.ts b/gui/app/src/utils/skeletonMeshHelper.ts index 3325b15ccb..7ea6b691c0 100644 --- a/gui/app/src/utils/skeletonMeshHelper.ts +++ b/gui/app/src/utils/skeletonMeshHelper.ts @@ -1,6 +1,6 @@ import { Matrix4, Mesh, Object3D, Quaternion, Vector3 } from 'three'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; -import { BoneKind, getBoneList } from './skeletonHelper'; +import BoneKind, { getBoneList } from './skeletonHelper'; import { BoneShapeConfig, CYLINDER_GEOMETRY, diff --git a/gui/app/src/utils/skeletonParts.ts b/gui/app/src/utils/skeletonParts.ts index 3ab00d53aa..88f35ed358 100644 --- a/gui/app/src/utils/skeletonParts.ts +++ b/gui/app/src/utils/skeletonParts.ts @@ -21,12 +21,12 @@ export interface BoneShapeConfig { scaleRatio: Vector3 ) => { scaleX: number; scaleY: number; scaleZ: number }; } - export interface BonePartConfig { visible: boolean; joint?: number; shapes: BoneShapeConfig[]; } +export const BUST_GEOMETRY: BufferGeometry = new SphereGeometry(1, 20, 16); /** Flat-capped tube spanning `y` in [-1, 1], radius 1 (default primitive). */ export const CYLINDER_GEOMETRY: BufferGeometry = new CylinderGeometry(1, 1, 2, 20); @@ -98,6 +98,25 @@ export const SKELETON_PART_PRESETS: Record = { } ), ]), + [BodyPart.LEFT_BUST]: part( + shape( + { x: 4, y: 4, z: 4 }, + { + geometry: BUST_GEOMETRY, + localOffset: new Vector3(0.05, -0.01, 0.0), + } + ) + ), + + [BodyPart.RIGHT_BUST]: part( + shape( + { x: 4, y: 4, z: 4 }, + { + geometry: BUST_GEOMETRY, + localOffset: new Vector3(-0.05, -0.01, -0.0), + } + ) + ), [BodyPart.WAIST]: part( shape({ x: 0.45, y: 1.05, z: 0.45 }, { modelUrl: '/models/skeleton/spine.gltf' }) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index 608715853e..d10fa1a578 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -51,11 +51,11 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_LITTLE_PROXIMAL to arrayOf(BodyPart.RIGHT_LITTLE_INTERMEDIATE), BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), - BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST), - BodyPart.CHEST to arrayOf( + BodyPart.UPPER_CHEST to arrayOf( BodyPart.LEFT_BUST, BodyPart.RIGHT_BUST, - BodyPart.WAIST), + BodyPart.CHEST), + BodyPart.CHEST to arrayOf(BodyPart.WAIST), BodyPart.WAIST to arrayOf(BodyPart.HIP), BodyPart.HIP to arrayOf(BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt index 368a0d43ba..f391596c10 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bone-direct-link.kt @@ -47,8 +47,8 @@ class BoneDirectLinkInputProcessor : SkeletonInputProcessor { BodyPart.RIGHT_RING_TOE to BodyPart.RIGHT_MIDDLE_TOE, BodyPart.RIGHT_LITTLE_TOE to BodyPart.RIGHT_RING_TOE, - BodyPart.LEFT_BUST to BodyPart.CHEST, - BodyPart.RIGHT_BUST to BodyPart.CHEST + BodyPart.LEFT_BUST to BodyPart.UPPER_CHEST, + BodyPart.RIGHT_BUST to BodyPart.UPPER_CHEST ) override fun process(mutableInputSkeleton: InputSkeleton, skeletonHeight: Float) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt new file mode 100644 index 0000000000..0639b48d40 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt @@ -0,0 +1,33 @@ +package dev.slimevr.skeleton.inputprocessors + +import dev.slimevr.skeleton.InputSkeleton +import dev.slimevr.skeleton.SkeletonInputProcessor +import io.github.axisangles.ktmath.EulerAngles +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles setting the rotation of an inactive bone with its source bone. + */ +class BustInputProcessor : SkeletonInputProcessor { + + override fun process(mutableInputSkeleton: InputSkeleton, skeletonHeight: Float) { + for (bodyPart in arrayOf(BodyPart.LEFT_BUST, BodyPart.RIGHT_BUST)) { + val bone = mutableInputSkeleton[bodyPart] ?: continue + val correctedRotation = invertBustPitch(bone.rotation) + mutableInputSkeleton[bodyPart] = bone.copy(rotation = correctedRotation) + } + } + + private fun invertBustPitch(rotation: Quaternion): Quaternion { + val euler = rotation.toEulerAngles(EulerOrder.XYZ) + val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() + return EulerAngles( + EulerOrder.YZX, + -euler.x, + euler.y, + euler.z, + ).toQuaternion() + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 624f5b3406..306313b501 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -17,6 +17,7 @@ import dev.slimevr.skeleton.inputprocessors.HeadPositionFallbackProcessor import dev.slimevr.skeleton.inputprocessors.HipYawRollAlignInputProcessor import dev.slimevr.skeleton.inputprocessors.SpineImputeInputProcessor import dev.slimevr.skeleton.inputprocessors.UpperLegsRollAlignInputProcessor +import dev.slimevr.skeleton.inputprocessors.BustInputProcessor import dev.slimevr.util.PreciseWaiter import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -200,6 +201,7 @@ class Skeleton( SpineImputeInputProcessor(settings), HipYawRollAlignInputProcessor(settings), UpperLegsRollAlignInputProcessor(settings), + BustInputProcessor(), BoneDirectLinkInputProcessor(), FingerImputeInputProcessor(), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index c1ba359b07..4bf68afad2 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -224,9 +224,10 @@ private fun getToeOffsets(footLength: Float) = ( /** * Returns the offsets for the bust bones scaled from the chestLength. */ -private fun getBustOffsets(bustLength: Float) = ( - iterateBodyPartHierarchy(BodyPart.LEFT_BUST, true) + - iterateBodyPartHierarchy(BodyPart.RIGHT_BUST, true) - ).map { it.second }.associateWith { - Vector3(0f, 0f, bustLength * 0.2f) - } +private fun getBustOffsets(bustLength: Float): BodyPartMap = + BodyPartMap( + mapOf( + BodyPart.LEFT_BUST to Vector3(0f, 0f, -bustLength * 0.2f), + BodyPart.RIGHT_BUST to Vector3(0f, 0f, -bustLength * 0.2f), + ), + ) diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index b81a615657..dce81f6b57 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -145,7 +145,7 @@ val VMC_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_RING_TOE, BodyPart.RIGHT_LITTLE_TOE, ), - BodyPart.CHEST to arrayOf( + BodyPart.UPPER_CHEST to arrayOf( BodyPart.LEFT_BUST, BodyPart.RIGHT_BUST ), diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt index dc7e7cc2fa..17c703f5d3 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt @@ -12,7 +12,7 @@ private const val MAXIMUM_ABSOLUTE_BUST_RANGE = 90 internal fun buildBustMessages(bones: Map): List { val messages = mutableListOf() - val chest = bones[BodyPart.CHEST] + val chest = bones[BodyPart.UPPER_CHEST] if(chest != null) { val leftBust = bones[BodyPart.LEFT_BUST] diff --git a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt index ca1a55e317..c12e2a9832 100644 --- a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt +++ b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt @@ -1,5 +1,6 @@ package dev.slimevr.skeleton +import dev.slimevr.skeleton.inputprocessors.BoneDirectLinkInputProcessor import dev.slimevr.skeleton.inputprocessors.ToeDirectLinkInputProcessor import io.github.axisangles.ktmath.Quaternion import org.junit.jupiter.api.Test @@ -9,9 +10,9 @@ import kotlin.test.assertTrue class BustDirectLinkInputProcessorTest { @Test fun `test all missing bust trackers`() { - val processor = ToeDirectLinkInputProcessor() + val processor = BoneDirectLinkInputProcessor() val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutateCopy { map -> - map[BodyPart.CHEST] = map.getValue(BodyPart.CHEST).copy( + map[BodyPart.UPPER_CHEST] = map.getValue(BodyPart.UPPER_CHEST).copy( rotation = Quaternion.fromRotationVector(10f, 40f, 15f), isRotationActive = true, ) @@ -28,10 +29,10 @@ class BustDirectLinkInputProcessorTest { val newInputs = processor.process(state.boneInputs, state.skeletonHeight) val leftBustIsSameRotationAsChest = - newInputs[BodyPart.LEFT_BUST]?.rotation == newInputs[BodyPart.CHEST]?.rotation + newInputs[BodyPart.LEFT_BUST]?.rotation == newInputs[BodyPart.UPPER_CHEST]?.rotation val rightBustIsSameRotationAsChest = - newInputs[BodyPart.RIGHT_BUST]?.rotation == newInputs[BodyPart.CHEST]?.rotation + newInputs[BodyPart.RIGHT_BUST]?.rotation == newInputs[BodyPart.UPPER_CHEST]?.rotation assertTrue(leftBustIsSameRotationAsChest) assertTrue(rightBustIsSameRotationAsChest) From 056f53f92cf1e764359086160ea43de9e988a234 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Thu, 3 Sep 2026 00:52:30 -0500 Subject: [PATCH 06/16] Initial bust correction math, --- .../inputprocessors/bust-input-processor.kt | 193 +++++++++++++++++- 1 file changed, 183 insertions(+), 10 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt index 0639b48d40..b0a313e824 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt @@ -6,26 +6,199 @@ import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import solarxr_protocol.datatypes.BodyPart +import kotlin.math.abs /** - * Handles setting the rotation of an inactive bone with its source bone. + * Handles bust-specific rotation behavior: + * + * - Inverts the normal bust pitch. + * - Adds temporary pitch motion from vertical acceleration. + * - Uses a damped spring so acceleration-induced motion settles + * back to the actual tracked rotation. */ class BustInputProcessor : SkeletonInputProcessor { - override fun process(mutableInputSkeleton: InputSkeleton, skeletonHeight: Float) { + private data class BustMotionState( + var pitchOffset: Float = 0f, + var pitchVelocity: Float = 0f, + var verticalAcceleration: Float = 0f, + ) + + private val states = mutableMapOf( + BodyPart.LEFT_BUST to BustMotionState(), + BodyPart.RIGHT_BUST to BustMotionState(), + ) + + private var lastUpdateNanos = System.nanoTime() + + companion object { + /** + * How strongly vertical acceleration affects pitch. + */ + private const val ACCELERATION_SENSITIVITY = 0.12f + + /** + * How strongly the temporary pitch offset is pulled back toward zero. + * + * Higher = snaps back faster. + */ + private const val SPRING_STRENGTH = 20.0f + + /** + * Resistance to oscillation. + * + * Higher = less bouncing. + * Lower = more secondary motion / jiggle. + */ + private const val DAMPING = 6.0f + + /** + * Ignore tiny accelerometer fluctuations. + */ + private const val ACCELERATION_DEADZONE = 0.10f + + /** + * Maximum temporary pitch offset, in radians. + * + * ~15 degrees. + */ + private const val MAX_PITCH_OFFSET = 0.2617994f + + /** + * Avoid giant simulation steps after pauses/debugger stops. + */ + private const val MAX_DELTA_TIME = 0.05f + } + + /** + * Feed vertical/linear acceleration for a bust tracker into this processor. + * + * Positive/negative direction may need to be flipped depending on + * SlimeVR's acceleration coordinate system. + */ + fun setVerticalAcceleration( + bodyPart: BodyPart, + acceleration: Float, + ) { + val state = states[bodyPart] ?: return + state.verticalAcceleration = acceleration + } + + override fun process( + mutableInputSkeleton: InputSkeleton, + skeletonHeight: Float, + ) { + val now = System.nanoTime() + + var deltaTime = + (now - lastUpdateNanos).toFloat() / 1_000_000_000f + + lastUpdateNanos = now + deltaTime = deltaTime.coerceIn(0f, MAX_DELTA_TIME) + + val chest = + mutableInputSkeleton[BodyPart.UPPER_CHEST] + ?: return + + val chestRotation = chest.rotation + for (bodyPart in arrayOf(BodyPart.LEFT_BUST, BodyPart.RIGHT_BUST)) { - val bone = mutableInputSkeleton[bodyPart] ?: continue - val correctedRotation = invertBustPitch(bone.rotation) - mutableInputSkeleton[bodyPart] = bone.copy(rotation = correctedRotation) - } + val bone = mutableInputSkeleton[bodyPart] ?: continue + val state = states.getValue(bodyPart) + + updateMotion(state, deltaTime) + + // Convert absolute bust rotation into chest-local rotation. + val localRotation = + chestRotation.inverse() * bone.rotation + + // Modify only the rotation relative to the chest. + val correctedLocalRotation = + applyBustRotation( + localRotation, + state.pitchOffset, + ) + + // Convert back to absolute/skeleton rotation. + val finalRotation = + chestRotation * correctedLocalRotation + + mutableInputSkeleton[bodyPart] = + bone.copy(rotation = finalRotation) + } + } + + /** + * Updates the temporary acceleration-driven pitch offset. + * + * Acceleration pushes the velocity, while a damped spring pulls + * the pitch back toward the actual tracker rotation. + */ + private fun updateMotion( + state: BustMotionState, + deltaTime: Float, + ) { + if (deltaTime <= 0f) { + return } - private fun invertBustPitch(rotation: Quaternion): Quaternion { + var acceleration = state.verticalAcceleration + + if (abs(acceleration) < ACCELERATION_DEADZONE) { + acceleration = 0f + } + + // Acceleration creates temporary pitch velocity. + // + // Flip this sign if upward acceleration produces motion + // in the wrong direction: + // + // state.pitchVelocity -= ... + state.pitchVelocity += + acceleration * + ACCELERATION_SENSITIVITY * + deltaTime + + // Damped spring pulling the offset back toward zero. + val springAcceleration = + (-SPRING_STRENGTH * state.pitchOffset) - + (DAMPING * state.pitchVelocity) + + state.pitchVelocity += springAcceleration * deltaTime + state.pitchOffset += state.pitchVelocity * deltaTime + + state.pitchOffset = + state.pitchOffset.coerceIn( + -MAX_PITCH_OFFSET, + MAX_PITCH_OFFSET, + ) + + // If we've basically settled, kill tiny residual motion. + if ( + abs(state.pitchOffset) < 0.0001f && + abs(state.pitchVelocity) < 0.0001f + ) { + state.pitchOffset = 0f + state.pitchVelocity = 0f + } + } + + /** + * Inverts the normal pitch and adds the temporary + * acceleration-induced pitch offset. + */ + private fun applyBustRotation( + rotation: Quaternion, + pitchOffset: Float, + ): Quaternion { val euler = rotation.toEulerAngles(EulerOrder.XYZ) - val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() + return EulerAngles( - EulerOrder.YZX, - -euler.x, + EulerOrder.XYZ, + + // Inverted actual pitch + temporary inertial motion + -euler.x + pitchOffset, + euler.y, euler.z, ).toQuaternion() From 2c6cc8d3e5fff4fe7dd275ec1b2b19abed9fb963 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Thu, 3 Sep 2026 00:55:39 -0500 Subject: [PATCH 07/16] Change function call --- .../slimevr/skeleton/inputprocessors/bust-input-processor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt index b0a313e824..945eb13469 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt @@ -110,7 +110,7 @@ class BustInputProcessor : SkeletonInputProcessor { // Convert absolute bust rotation into chest-local rotation. val localRotation = - chestRotation.inverse() * bone.rotation + chestRotation.inv() * bone.rotation // Modify only the rotation relative to the chest. val correctedLocalRotation = From d21a4cc74085d9e1faa01fe7a47678dc4e52f842 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Sun, 6 Sep 2026 23:04:08 -0500 Subject: [PATCH 08/16] Adjust bust logic --- .../skeleton/inputprocessors/bust-input-processor.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt index 945eb13469..b8359f95ba 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/inputprocessors/bust-input-processor.kt @@ -35,7 +35,7 @@ class BustInputProcessor : SkeletonInputProcessor { /** * How strongly vertical acceleration affects pitch. */ - private const val ACCELERATION_SENSITIVITY = 0.12f + private const val ACCELERATION_SENSITIVITY = 0.6f /** * How strongly the temporary pitch offset is pulled back toward zero. @@ -154,14 +154,14 @@ class BustInputProcessor : SkeletonInputProcessor { // in the wrong direction: // // state.pitchVelocity -= ... - state.pitchVelocity += + state.pitchVelocity -= acceleration * ACCELERATION_SENSITIVITY * deltaTime // Damped spring pulling the offset back toward zero. val springAcceleration = - (-SPRING_STRENGTH * state.pitchOffset) - + (SPRING_STRENGTH * state.pitchOffset) - (DAMPING * state.pitchVelocity) state.pitchVelocity += springAcceleration * deltaTime From be2388846c6fa62b26aeb7e5b6ba05d36612c951 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Tue, 8 Sep 2026 23:46:48 -0500 Subject: [PATCH 09/16] Adjustments to bust physics. --- .../vrcosc/bust-plugin-output-encoder.kt | 350 +++++++++++++++--- .../vrcosc/toe-plugin-output-encoder.kt | 2 +- 2 files changed, 305 insertions(+), 47 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt index 17c703f5d3..ae41ab08c8 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt @@ -1,68 +1,326 @@ package dev.slimevr.vrcosc -import dev.slimevr.osc.OscArg -import dev.slimevr.osc.OscContent -import dev.slimevr.osc.OscMessage import dev.slimevr.skeleton.BoneState -import dev.slimevr.util.Side import io.github.axisangles.ktmath.EulerOrder import solarxr_protocol.datatypes.BodyPart +import dev.slimevr.util.Side +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import kotlin.math.abs +import kotlin.math.exp +private val bustOutputState = BustOutputState() -private const val MAXIMUM_ABSOLUTE_BUST_RANGE = 90 +internal fun buildBustMessages( + bones: Map, +): List = + bustOutputState.buildMessages(bones) -internal fun buildBustMessages(bones: Map): List { - val messages = mutableListOf() - val chest = bones[BodyPart.UPPER_CHEST] +private class BustOutputState { + private var verticalBaselineY = 0f + private var verticalPosition = 0f + private var verticalVelocity = 0f + private var lastUpdateNanos = 0L + private var baselineInitialized = false - if(chest != null) { - val leftBust = bones[BodyPart.LEFT_BUST] - processBust(chest, leftBust, Side.LEFT, messages) + fun buildMessages( + bones: Map, + ): List { + val messages = mutableListOf() + val chest = bones[BodyPart.CHEST] + val leftBust = bones[BodyPart.LEFT_BUST] val rightBust = bones[BodyPart.RIGHT_BUST] - processBust(chest, rightBust, Side.RIGHT, messages) + + if (chest != null) { + if (leftBust != null) { + processBust( + messages, + chest, + leftBust, + Side.LEFT, + ) + } + + if (rightBust != null) { + processBust( + messages, + chest, + rightBust, + Side.RIGHT, + ) + } + } + + val verticalAccelerationY = + getSharedVerticalAccelerationY( + leftBust, + rightBust, + ) + + val vertical = + updateVerticalResponse( + verticalAccelerationY, + getDeltaTimeSeconds(), + ) + + addFloat( + messages, + "BustVertical", + vertical, + ) + + return messages + } + + private fun processBust( + messages: MutableList, + chest: BoneState, + bust: BoneState, + side: Side, + ) { + val currentRelative = + chest.rotation.inv() * + bust.rotation + + val euler = + currentRelative.toEulerAngles( + EulerOrder.XYZ, + ) + + val pitch = + Math.toDegrees( + euler.x.toDouble(), + ).toFloat() + + val yaw = + Math.toDegrees( + euler.z.toDouble(), + ).toFloat() + + addFloat( + messages, + "${side.oscName}BustPitch", + (pitch / 90f) + .coerceIn(-1f, 1f), + ) + + addFloat( + messages, + "${side.oscName}BustYaw", + (yaw / 90f) + .coerceIn(-1f, 1f), + ) + } + + private fun getSharedVerticalAccelerationY( + leftBust: BoneState?, + rightBust: BoneState?, + ): Float? = + when { + leftBust != null && + rightBust != null -> + ( + leftBust.acceleration.y + + rightBust.acceleration.y + ) * 0.5f + + leftBust != null -> + leftBust.acceleration.y + + rightBust != null -> + rightBust.acceleration.y + + else -> + null + } + + private fun updateVerticalResponse( + accelerationY: Float?, + dt: Float, + ): Float { + if (accelerationY != null) { + if (!baselineInitialized) { + verticalBaselineY = + accelerationY + + baselineInitialized = + true + } + + val baselineAlpha = + 1f - + exp( + -dt / + VERTICAL_BASELINE_TIME_CONSTANT, + ) + + verticalBaselineY += + (accelerationY - + verticalBaselineY) * + baselineAlpha + + var dynamicAcceleration = + accelerationY - + verticalBaselineY + + if ( + abs(dynamicAcceleration) < + VERTICAL_ACCEL_DEADZONE + ) { + dynamicAcceleration = 0f + } + + val inertialInput = + -dynamicAcceleration * + VERTICAL_ACCEL_GAIN + + stepSpring( + inertialInput, + dt, + ) + } else { + stepSpring( + 0f, + dt, + ) + } + + if ( + abs(verticalPosition) < + SNAP_POSITION_EPSILON && + abs(verticalVelocity) < + SNAP_VELOCITY_EPSILON + ) { + verticalPosition = 0f + verticalVelocity = 0f + } + + return verticalPosition + .coerceIn(-1f, 1f) + } + + private fun stepSpring( + input: Float, + dt: Float, + ) { + val restoring = + -VERTICAL_SPRING * + verticalPosition + + val damping = + -VERTICAL_DAMPING * + verticalVelocity + + verticalVelocity += + ( + input + + restoring + + damping + ) * dt + + verticalPosition += + verticalVelocity * dt + + if (verticalPosition > 1f) { + verticalPosition = 1f + + if (verticalVelocity > 0f) { + verticalVelocity = 0f + } + } else if (verticalPosition < -1f) { + verticalPosition = -1f + + if (verticalVelocity < 0f) { + verticalVelocity = 0f + } + } } - return messages + private fun getDeltaTimeSeconds(): Float { + val now = + System.nanoTime() + + if (lastUpdateNanos == 0L) { + lastUpdateNanos = now + return DEFAULT_FRAME_DT + } + + val dt = + ( + (now - lastUpdateNanos) + .toDouble() / + 1_000_000_000.0 + ) + .toFloat() + .coerceIn( + MIN_FRAME_DT, + MAX_FRAME_DT, + ) + + lastUpdateNanos = now + + return dt + } + + companion object { + private const val VERTICAL_ACCEL_DEADZONE = + 0.025f + + private const val VERTICAL_ACCEL_GAIN = + 18f + + private const val VERTICAL_SPRING = + 34f + + private const val VERTICAL_DAMPING = + 9f + + private const val VERTICAL_BASELINE_TIME_CONSTANT = + 2.5f + + private const val DEFAULT_FRAME_DT = + 1f / 60f + + private const val MIN_FRAME_DT = + 1f / 240f + + private const val MAX_FRAME_DT = + 0.05f + + private const val SNAP_POSITION_EPSILON = + 0.0025f + + private const val SNAP_VELOCITY_EPSILON = + 0.01f + } } -private fun processBust( - chest: BoneState?, - bust: BoneState?, - side: Side, +private fun addFloat( messages: MutableList, + parameterName: String, + value: Float, ) { - if(bust == null) return - - // Guard against null chest - if(chest == null) return - - val bustRot = bust.rotation - val currentRelative = chest.rotation.inv() * bustRot - val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) - val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() - val yaw = Math.toDegrees(euler.z.toDouble()).toFloat() - val bustPitch = (pitch / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) - val bustYaw = (yaw / MAXIMUM_ABSOLUTE_BUST_RANGE).coerceIn(-1f, 1f) - messages.addAll( - listOf( - OscContent.Message( - OscMessage( - "/avatar/parameters/${side.oscName}BustPitch", - listOf(OscArg.Float(bustPitch)) - ) - ), - OscContent.Message( - OscMessage( - "/avatar/parameters/${side.oscName}BustYaw", - listOf(OscArg.Float(bustYaw)) - ) + messages.add( + OscContent.Message( + OscMessage( + "/avatar/parameters/$parameterName", + listOf( + OscArg.Float( + value.coerceIn( + -1f, + 1f, + ), + ), + ), ), ), ) } private val Side.oscName: String - get() = when (this) { - Side.LEFT -> "Left" - Side.RIGHT -> "Right" - } + get() = + when (this) { + Side.LEFT -> "Left" + Side.RIGHT -> "Right" + } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt index bae5807303..41b1c290d7 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt @@ -92,7 +92,7 @@ private fun processToe( val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) val pitch = Math.toDegrees(euler.x.toDouble()).toFloat() - val yaw = Math.toDegrees(euler.z.toDouble()).toFloat() + val yaw = Math.toDegrees(euler.y.toDouble()).toFloat() val tipToe = pitch < MINIMUM_TIP_TOE_PITCH val bending = pitch > MINIMUM_BENDING_PITCH val splayed = when (splayDirection) { From ea761c4e6b41ead9e96bb3e9c162aa05d792a517 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 9 Sep 2026 00:34:41 -0500 Subject: [PATCH 10/16] Fix bust representation in updated preview skeleton. --- gui/app/public/models/skeleton/breast.bin | Bin 0 -> 23648 bytes gui/app/public/models/skeleton/bust.gltf | 104 ++++++++++++++++++ gui/app/src/utils/skeletonParts.ts | 23 ++-- .../java/dev/slimevr/resets/bodypart-sets.kt | 7 +- 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 gui/app/public/models/skeleton/breast.bin create mode 100644 gui/app/public/models/skeleton/bust.gltf diff --git a/gui/app/public/models/skeleton/breast.bin b/gui/app/public/models/skeleton/breast.bin new file mode 100644 index 0000000000000000000000000000000000000000..3d79c2dcd5089f091a374d9c7e67e744981b4690 GIT binary patch literal 23648 zcmeI3d6*W()%L3&K-Pg_M?gT?H)RtMnWsll7DXAOiN=^n-YoJad0&Z$E6OPDD{+g8 z3(>g71p#-bYXn5W1w_Ox1~l$bM8p+jzTaJa3j4dh>-#r<#p^nCy6>(!=RW7DK2_C& z&N(;y%_Gw{_HFICbm)}(aJ}^DiRG-r8R1&boU4s^7IBSJ|5opHs{%_qel|WE{r+`T zznJ@n^pL^z+5O+XRVDlBSuOno2UL{5XuNlN-o?ZHOI`bwSuR~^gZ zytVN|*{X+rS{CP;-!}5=F1a{xKB=m|um9-Q!1>@i5A<6aKNL8hcFr;W;KB8QbIyNR z;QaHse~5VCY5g^Xui;_;~R#o{BrWmwbxzny&ov~ zz3iJ`J&%06_!v(wp7-;+i}QU4RAh=*oL|1=;_Og(D$j@XUYX%O)!ab)PIEhHKBgV9 zKeMWT;H>=cy9UnZ^7~p}x4H4&5zmBM|4jYOj+Y&?FYV2*mtW)K#m9Jh@q~Bae9yOU z)e%qN{Om(N&3;Zk#d&74n=|4MoXJPFeA}?V`55xR?;1EC&hJPb1LrE{;V{PG->v7J zF+3A){WJA9J6?9ozVaRYdigazUVMzF7f;#d9hHw-UH#|!- zo(Z@9nfhz~V8_dj*;ieOe!ct}A1^+}(~D<2>a938eXm>HWa^kW3olb2#QApSmEJqO zZwq-Z`Z1ryc@^)qc&eVfLS4FV|C0mf*3{J}uqVzBJv}kvnQ-f$slVCrvSaqW?3-UN zzs9G?(~D=8uS@u-9?=2+;_O`gh}YmH&dyC6xdpy?ji_C;Czv?XXC!qwaAxe{C*hf| zG4&(2HO%3;{u?&RaB9-d{#;itHalLp+2^`;k@@9%+DPN0J|m1L*VQB1TAsE3#60iA z`osGlI6p{Tr+x*_)Sq%+%kwMLp_u2@%&&Vb&%0vpQOom6@?m%;Jmy*b&5oBHv#iVR79%?-*$N9XO_h75dXV%~H zQ8u47Zw$|b$N8-OW=HEtwC`o#{DyTh&S&v4o?bkkx4L~7ae1rTE8%&x)$Nm5C$%n! z^Ay&fr>t&keT#K_D)A|Anj4yr1H+th?sevn;bES}x~=|ZC#;iZ-^;%F_3~?cy!aST zFP`_<`uI2WOFyiS<%zaF%Fd34hOnkfxBp>%Oyl}^BEQ2qKdg`G$Hb@E{5jfit&g#P zrv7F}>tky6y|noa{Ed&+$FlMC;>r6N_92R6Iq~Qn)V}5f+lTy&e2Ta7tb82Bz23p4 z@Z+XPH`KAQLq&xBk5 zO#RJ{mmRYo_9^Dq%dheA;$u9$cs90ubp`MBVE8M~6Y+lo`4?y9N$;~b>-T+zIK{aS z`HcH&@oC0*&F6clA9tX|`C)QlcqTmVtJUA^c-b-gUiQtemtW)K#m9Jh@x=5Z<@vGT zXWC+MZWr>jX0bTG5`O2}MdGaa>DwAHx{uDwl}!W1-G1Tq4e2hB_Va zEXPCtEbkxe_}osgPd;To`1QHpz{lr&0#7fVVO_ybEBNor`WN%Om~}{;#}Y65!%Up# zl81GyTjJcC{Jg+=CeB}wx2~*n;(RyxT+R9?&Wo^XcqZKXXXTh@c5Y-^;%F_3~?cy!aSTFP^uNPjOz!JXL>juH!vBk$tT= z|0u5;fwSt8_QA@t=Y2nv{joUfJ)$mq<#`YC#`^2Uxi{kt&xBk5O#RJ{mmTUx;4J&* z*UPW*@#14Vy?EZm?=H@F@}4PPab826n1mg1K7cy+eM3Xwyq)^lf%Aqqx5fSpokQUK zNAkn(8aU74_uWL!#QD8^J_*l+TmMY`&5oBHv+rf!{CfE{K3;r`rx#DzW^W=NwYvJx z^}ja{%$qQ07HFO1z0;go$~yBC=CkI^?^q{wE>%4_nBS58Lzpv1Fb^kk-qxJay1;%T z%$YIN0rnwb&OFcDGCUJ*{WJA9J6?9ozL$OT>*d$@c=0iwUOZP)$289evCe!(eGupN ztdqRYUYsAMKks?qtbGCRf8hKu@5@Pi*AQpMsSXCtRjdm~@Et_6Z~LZDAHzHsZv8X$ zH#=T-%)Xa>^Xui;_;~R#o?bj#P;bR~$GhFC4x)~UvvBefIA1|rdhe8H?OUh|f%8+m z*IVEz&e}gxmjma9P2lr5DPFTj3GwR8QoMeI zcvVZCo42HRy}4nI*Kg%%&+vwyfYn_Ta-AFho9ws!If*Tz{sBL8r0F><{# z-f(R>;d*C&!nNg)>z(-w*UfEiG~>*8pv?`=ICcBj+<1t!h`n*hA>R(OUN$#g<=th? z3%TM9mEB=;V|VP`XLI9aenZ1E;nqJ>f3xFd$L#CdWM+Q7{2CuGKE~6F=QH*WYR&xA z-a(z&C)qpr3~MH9L*T61Ki=L!t;sX(9n@OA$=<;!#688{L9OkEXTq(2rv7F}d**0g zXRhei%dhbX-w=$a7ta+|>ux13_PSvX>zl(At95^5ZP!|+xvFn&PguF$hWD^q_bPMWaGkld=TQGl{moAJR%Z6YH$C&~<=6OxZy?6gi|6e>tm)Z{KdkB5 zI9t;vpuhWJP0w1}n$DTYpI~dc^lV$x^*g_9Yx*Z>!^7`y{WEE^<7LO}YfX=Sz5E&< zt?8NZ)S8~%2yfL5_U4(ly_3HA{Koc9)cj0)1Law@|1j=t@8qYDFXdVJ8%-YdzO(1_ zD{b%8k9-@R3Ag^?_c1%#J9)DozNwmDtu>KP_y%P>y?72_eu(o5=5<@!qiVk02Y;<8 zli{_RIK^4De-ZJB^MUaEgnr_zc{-N)D$bjk*WYZ)_o!2u*M?`pt$&!;X2;8p+1DP` zn_n-##>b0~@$};P{kz=)=cc^Z{o${gtoOB+{EPG6aQ*D%6Uqbn{J(!Q0?#5&f3N-n=Jl#-`poI+W$Pwqlbg@4y7J54r*|x0 znl-v}LzUm1v*2Z|eB-qhThb1Twc zbw0?yIQ*gV#5GIH;~zW5uOD4s9zAbDd9Pij`|ZpZao&^P#qdnH_0QDb?0DHR`(F0V zua{rrAO`|!ugYKgR^_@J+{0Lc^=vG_UxRhTgqF>bH^#0vJbyqS?)oe z=ML=br?mQ%y<=xq4K zE`6|iREqYGzuqZbcXmaJ_N|WZpY~k4w2bz1t{I*_&)h)sx3v757&_vaaOL_uJQH_D{HgLxJ~Hw`ovuj9lkyevO#YaY zH6hRB+mqi^bAxLN_!v(wp0Z6{2tNLE{qN5M?`K){ zg!i?M_cqiM-s6n-IaNL3{myvLQ`HmR`;7NLRXw2|s4k?cC)5emi&XW5`jJsb!tY64 ziFhX5`e*8Ic2rlQebtrdS9K*bKB_C3@$}+J9W1MEQy1%~k7dSY~uGgaNDj@D66 zQ`K$iYet<-Rkx|Ts=uk~HuYF_IaS@JPG{8Xz?u3T@l3e&&(z=SsD5W=U-dgPzpCGn zkLq{isdc9AthI-h&n3^AjxaxLVsM}g!RsX{}Y4*c9X@0{xS*Y9PLfx(^tdHfw`dBWk zkLAMpsC6-}k7;3jObhEH|Mq3)`WSJokFmej$7m<4lZEwB>#^45uud8ut&iox`dBXP zL&}AHNV%{NDHrx3S{LI!BrWVi(!xF@E$l-gu6;=CAJ)~vK1A!T_Bq;zq=kJ*-t1icVwd!)*SEsg54)wtH&!J8f_SLE3p-x-> zP^Sy~>S$m4YOM#_&ud?;bwc}m?W-eC?W;pQRee_dQ>|2eR-M+hoi$Qjt3Ioa>RR<# z^;OrZ&#Jq+wlhe$)_ttgb_R+4v)CUyI*$Y#`##!NeO8^81^D>L2cBL$wXWFtMR~5X z^NaFaXXh8?A+z&KSpV$&qC97Ieo;O%JHMn_|Kv|~+VGfX^^bYZu%q)!v>)>0MUm~AaH^ej44V?$<{G$4t+4)8F zIkWSN>a(}=i|VSk^NZ@OxATkYu($Jz>T}?%I&FBY&*~rRv+TtBEc@XcV}4_O7N1z3 z#Z%{(@UHQ$1Ru)Hf3EW^{JZkNdXw4tQTvcE&$Ui6&%!*{dd0fs?fj^H2;bqt`BD3j zFweD4>Kv|lu60u9b48(b((t$s3G-b2%}(5h$bQ_1gn2H%#z*Hz?L)*<=f^76o6OEH z+V6#WqIHS&$=mrw`#o>x7wyx$onN#rdON>pUG#Q-(S9#*);ei;-0!J>-0#Uw-0#W0 z*E(!|<9;vH6Y(*gI=@u0-eh)u(LOe_^NaS?-p()DS9?3ZXg}=j{GxTy+xbQ7qPO#l z_SJ#2)=9(TzFPg`zFKzTzFPLZ)?xD-zCRhCxUUw^u&-vl$>RB?c+HxW?L5mAulY6? z&o%l^7q3~{d_3b6uUY@R)>F-u;x%iek7u9awZ7%Wb5Qa6|M#v~pRi68ugzw_i`T{> z%!d7Su)Ozy29Mh**4A%ok`=|(Apg5hSuyj zH*~fQe(Y>3Jf3ai+=zBGH*~g*b36=xob^4YSYn{H8#agGeIo3MfpPjW%-^v1K`&OnJ7{8UpTBo&Kc2w)4ebu_? zH-0MjkDans$1IR0iG9uorqv%D*|Nl4ePwpR~r0Nw+u5v3uDW!@phib}xIOe;A+J(|xaS zpSWR$%a`z)pc@*F3p#N#(8J8H`pU1g{0=j}x|d(+dea+CD-Pk6rsa27n)?+lztS~9 z=YI8k2KNKZ?*OhH_N1$W&g0enf^eV5t9$tk{kfK|G%dda%&&0ym6qQDY3^6g<+qpl z)foAemS5@Gpm#LfVp@3?F2A8a*V6JUol73CuB#1`U)@g&I``Yf{B{A8UupU6VtLm6 zf^eV5t9$tk{kfKwUuoIyV)5!;ex>DCT7LC>2D)|}{@gbWDRWEzEikInfu^UJ z&c9tdcdXt2+I0Rc#JQ8~ezxgK%e!)?yh|(Zm6mtiEAN%|Ug=(W5B<58j-tF*THb{z z@6t7EP{5Hd%=9#qSnV!pU=pV-CwskMR&CGAGW6$MR_wuXf z^6Sj6({;Ile?OV~b-}OhbHC2~s^3QI7sha38SKcf`sedo&#J?H9p) zZN{JCl~%l)%X*(18a^|>o6(9_&*k?sv~1~Kex>F2GxHnl7_NAAFTb04u6Q?^U*%5m zZops4^+xl%!TfGCzv?Hy8`M9H&;1IQ-wozh_lj5jEN-s080;?)@W)xG@c`3&?z^Scn-Aq&l~wBlW8ex+qY{p9xnwEXH`ex>Di zq50K)wc+xsd->IK`CVXs7l6y}0`n^^zYEN-wEU`{@_Y+geswRu((=2&{OVqQrR7&z ze)U{_=b7Jm;L7tA=69a?oo9Zf-@KpvDi?aEr7MFS`PF?suWG@o?Rkx9`K>j-(=6U;vV%-B zztXZf&HPF$UiI6^v*XZ;SNHNOt$3%IU)@(5u6T7Xzk05CtIcmUxWjg8eyh!IwfU9y z;d!3tRQ-uVT7IjsC%@9ef|lR>x%|q${Ob9PwB&kOo_m_#o@LfZ?Dq_QrFW2L^~?RL zpW+Suu`68}?8vX~50KVAKwA3%Y3&1~wGWWiK0sRg0BP+5q_q!_);>U5`v7U}1EjSNkk&px zTKfQL?E|E>50KVAKwA3%Y3&1~wGWW4ugce_dgYP4ddqu#RX)G#E$_Nl-aY;FdrK?t z(v`tZKF{i_^1Mqc?;fqZ%lV8K)&xqUOuYviovAi*8?ICop zcwO+Tdz)wYsSeNOH}5CE>KFXFoO^BDhp_k5oYmZu4tvosCv4t3%L6%_1MXbTtCn{s zyazlFc~OqTbMchDTx*2mTKCfWPrP*>_&duR`=egrc^*ft?WY{it3&@>>%PYBrS;#m zaBuGu&u7?k=~F|T4GpJ+_;Q`X@6-^F?)CfT_nvj+6vk|%rbs*10qM$MNBnfJ-&g&` zNB82B_Y)7{@;k%wJHz~*pm!TTGtBP^=2!Rfdjk2~fZr1sE5Fk6I|F<2dxH7Zz5JdK z{OWlgw{TrgH^0-(@A2k$dhn~~dA!o{dwlRKo%@xR-|4}xbRMtn<@b2=JDom?S9s1L zpI6g@-yNJ`^LVEPzdGaPeswRu$635O>ndJp`JER0>OS|Yd)4dX%&&arlKVZ{;yv2p zJu2NA@_e-UJ<9y*UVe`qHNQ3VQM@%gm)}X|car&?XnrS|--+f| z_wqZ@;+@D?`IVO6N#=K=`JDtNzZ1=`?&Wuq`K=Cq8yeE!H($%{ zb?w~1QUWrt)WWrNLrX(S?P%M%_O1lhlD4I5<@PKMEw!g@?>e|fV6A9dxeC{>G~Mmu z{yW*T)WJ0dtDvoLtzG|8ztS$WySSz>YYZ}|)EagUtSN0%7`6f3jdnNJgS)1#6KyBg z8NO{mDv_RWZ3faD=|TjRAZ?Le@NEv#0@;-a+Jdx0c86;Vkd{bSB4`KF9@zt~EkRl# z-H4z)NC%`hTw8%uAl-@Mc-PTQPdb3N2J24S9d=E5(vh|!?DoLgFxp{mIAglHfwTkN zAV%x~(i<7Um~J55k-?1lt?T2CPkMvz1~!;>up5GuN%9umk>9nW2GZ=HA zn@Kyh5$6Hm1wWEOHRan1%g6Sk`ptD$(q8GVqTeMTmm>4&cP_|z$kmL#6y!4GMn<0paz1hmkzEFIIdT)R zoey#WaxJm_(p};HE4du}La=LTuXTSWwp#GBk$J>+A;?9@b;Ncy$T`TLV0ID6#mMzA zI|pPgawTJ?xtrZDlbhVVjJej`PJ6q%gE3ct&qHou%%4H7L+)hEJdi&j3$Sz@$o0rw zSo#ylmB?R+^LmgQkh_WVN|39NTZ!`qkom|x#Ca9S)yQqcIUnRkI(QN}EB%V?Lm<&3${JxTkdt7FVVAP*x?GvMV=wfg&+?i^~Cuo$YaQ}#Q7k|BIGIJd<^7qWF>Jf0$GeKCCbM^ zoo#7dA= z$m>{I3bG7Yi@l$_=iD!nRp86P*3zzZFB9jJ;C0CJ#JL>gX=EL7)`4Wm3&i;}$O_~Y z;>PufINe&hnWYdM^-cDIrj$b8}3cUyzJhgeaHQiG0%g%fc%{?>p)&X-et@S zATJ_sVd)i+^~ifzdJ$wb@(NYXvefK5pm+mXZYyjDae8z|mKt4pi zX3R#Aw~@`*`iteSwsc zPr$xo^uWGm7r`4ns$_O`k0Sla3uC0`#+@;O)o{2E~QF;BjM-#4&pf{*st zYoByTc3`a)ZL6dLOHDv_K{_T4SgW9|NLnY~xSzVF$uT_Xm^iT3w5^ji$+uW*O4~GP zM)VFOK`N8)@X?I6S<;;76Oa8p(K{JIJ0cm$nC{77+QG>X#vJAPCP(vRBx801>46MoOsAwTZQrCH zmU@8nME1l|XOJ#Pf8y*3(hJ#(IJf! zgY1vgz-(WTvB+dN?GJJQayVlSO=i>1PR?Y^gk%=&tmI_I90oEKIg2q9K_(%mFlH)9 z4KfEylRzdTr(&rFP^I1dIn1Ua2J$0sM!o|w!e&O<<^ zAZHNgfglGVC&6qA$f3w=m>mQ%0hz^^Biz}^kyyNfF{dRL(q5Qc#F!J4vuV#x&SA{y zAZH*KGiD~pNyuC*odGf%xdclmfy_eACC=F(XCjvp=PZzuk@JZ2OpvpX%ZT%2kW-NJ ziSsOwImqS2c?!s>$OUkk15%4z0kcy`7@p}i&f z3uCSWxgL3tF@FNN61j~r*Mr=EEXLB6AXg!`W9bHv`N%`Wc@@aj$eqMFALK^l5#qcW z$Cgd^Vyawc2f*p`AUL)H@6Dv;-pH;8Q+$a3UmVmrY-pUmLN8!%fA z@-(szW_8K)w9mutb*z0r`$6&{V_r%&(r!%NX2k0tZy+BrW-Z9e$UBVrU-xEm0#81| z(#s(0kbh$7`Q%O7HAIPVSc@N}$iB){C zRM1wGS`+;)AWe}@B?s1;wsomZ>D%P@u3701WT6w$JCFpaEPY1?n$b2ZHHTROQbO96 zwvmD6w9QK`7}2rRhqh0tFJoGlcB9>`)T7j_^gGwN^!ua}cqLd5+8(8zSZY@4OxwBC z1xuA6ZINDBY7Wu@*_Alkg0w?+C;Ap3Es?Io*$$*VvIo((1ZjnIBhL099gyBc-wLDx z=?=3FARUoDFslG*jqJvlT}#e&MFuct_fiwL2Qrv3T}uOK2b2=mnxB%0Sr?F95$AU2$sqU*g5CcCIbca4 literal 0 HcmV?d00001 diff --git a/gui/app/public/models/skeleton/bust.gltf b/gui/app/public/models/skeleton/bust.gltf new file mode 100644 index 0000000000..66ccded7ad --- /dev/null +++ b/gui/app/public/models/skeleton/bust.gltf @@ -0,0 +1,104 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.5.48", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 0 + ] + } + ], + "nodes":[ + { + "mesh":0, + "name":"Sphere" + } + ], + "meshes":[ + { + "name":"Sphere", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2 + }, + "indices":3 + } + ] + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":559, + "max":[ + 0.9999997019767761, + 1, + 0.9999993443489075 + ], + "min":[ + -0.9999990463256836, + -1, + -1 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":559, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":559, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5123, + "count":2880, + "type":"SCALAR" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":6708, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":6708, + "byteOffset":6708, + "target":34962 + }, + { + "buffer":0, + "byteLength":4472, + "byteOffset":13416, + "target":34962 + }, + { + "buffer":0, + "byteLength":5760, + "byteOffset":17888, + "target":34963 + } + ], + "buffers":[ + { + "byteLength":23648, + "uri":"breast.bin" + } + ] +} diff --git a/gui/app/src/utils/skeletonParts.ts b/gui/app/src/utils/skeletonParts.ts index 02375f7831..c5d458aa92 100644 --- a/gui/app/src/utils/skeletonParts.ts +++ b/gui/app/src/utils/skeletonParts.ts @@ -185,7 +185,6 @@ export interface BonePartConfig { /** Where an assigned tracker sits from this bone's head (0) to tail (1). */ trackerOffset?: number; } -export const BUST_GEOMETRY: BufferGeometry = new SphereGeometry(1, 20, 16); export const shape = (overrides: Partial = {}): BoneShapeConfig => ({ ...overrides, @@ -265,23 +264,17 @@ export const SKELETON_PART_PRESETS: Record = { { trackerOffset: CHEST_TRACKER_OFFSET } ), [BodyPart.LEFT_BUST]: part( - shape( - { x: 4, y: 4, z: 4 }, - { - geometry: BUST_GEOMETRY, - localOffset: new Vector3(0.05, -0.01, 0.0), - } - ) + model('bust', { + scale: authoredSize({ width: 0.08, depth: 0.08, length: 0.08 }), + offset: inMetres({ width: 0.07, length: -0.12 }), + }) ), [BodyPart.RIGHT_BUST]: part( - shape( - { x: 4, y: 4, z: 4 }, - { - geometry: BUST_GEOMETRY, - localOffset: new Vector3(-0.05, -0.01, -0.0), - } - ) + model('bust', { + scale: authoredSize({ width: 0.08, depth: 0.08, length: 0.08 }), + offset: inMetres({ width: -0.07, length: -0.12 }), + }) ), [BodyPart.WAIST]: part( diff --git a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt index e0a12056c1..d33dd092bf 100644 --- a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt +++ b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt @@ -8,11 +8,6 @@ object ResetBodyParts { BodyPart.RIGHT_UPPER_LEG, ) - val FEET = setOf( - BodyPart.LEFT_FOOT, - BodyPart.RIGHT_FOOT, - ) - val LEFT_TOES = setOf( BodyPart.LEFT_BIG_TOE, BodyPart.LEFT_INDEX_TOE, @@ -37,7 +32,7 @@ object ResetBodyParts { val FEET = setOf( BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT, - ) + TOES + ) val LEFT_FINGERS = setOf( BodyPart.LEFT_THUMB_METACARPAL, From f04d117b2d34df3be1ca372838d17a35cdd3974e Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 9 Sep 2026 07:06:46 -0500 Subject: [PATCH 11/16] Add bust assignments. --- .../src/components/commons/PersonFrontIcon.tsx | 16 ++++++++++++++++ .../components/onboarding/BodyPartAssignment.tsx | 4 ++-- gui/app/src/hooks/tracker-picker.ts | 4 ++++ .../slimevr/vrcosc/bust-plugin-output-encoder.kt | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/gui/app/src/components/commons/PersonFrontIcon.tsx b/gui/app/src/components/commons/PersonFrontIcon.tsx index 1b114ab8fe..f26a6769a0 100644 --- a/gui/app/src/components/commons/PersonFrontIcon.tsx +++ b/gui/app/src/components/commons/PersonFrontIcon.tsx @@ -3,6 +3,7 @@ import { BodyPart } from 'solarxr-protocol'; export const SIDES = [ { shoulder: BodyPart.LEFT_SHOULDER, + bust: BodyPart.LEFT_BUST, upperArm: BodyPart.LEFT_UPPER_ARM, lowerArm: BodyPart.LEFT_LOWER_ARM, hand: BodyPart.LEFT_HAND, @@ -15,6 +16,7 @@ export const SIDES = [ }, { shoulder: BodyPart.RIGHT_SHOULDER, + bust: BodyPart.RIGHT_BUST, upperArm: BodyPart.RIGHT_UPPER_ARM, lowerArm: BodyPart.RIGHT_LOWER_ARM, hand: BodyPart.RIGHT_HAND, @@ -113,6 +115,13 @@ export function PersonFrontIcon({ r={CIRCLE_RADIUS} id={BodyPart[SIDES[right].shoulder]} /> + + [ [BodyPart.HEAD, BodyPart.NECK], - [side.shoulder, side.upperArm], + [side.shoulder], [side.bust], [side.upperArm], [side.lowerArm, side.hand], [BodyPart.HIP], [side.upperLeg, side.lowerLeg, side.foot], @@ -50,7 +50,7 @@ const LEFT_GROUPS = (side: BodySide): BodyPart[][] => [ const RIGHT_GROUPS = (side: BodySide): BodyPart[][] => [ [BodyPart.UPPER_CHEST, BodyPart.CHEST], - [side.shoulder, side.upperArm], + [side.shoulder],[side.bust], [side.upperArm], [side.lowerArm, side.hand], [BodyPart.WAIST], [side.upperLeg, side.lowerLeg, side.foot], diff --git a/gui/app/src/hooks/tracker-picker.ts b/gui/app/src/hooks/tracker-picker.ts index 7c1b2f4a3d..4343ee8759 100644 --- a/gui/app/src/hooks/tracker-picker.ts +++ b/gui/app/src/hooks/tracker-picker.ts @@ -67,6 +67,10 @@ export const ALL_ASSIGNABLE_PARTS = [ BodyPart.NECK, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER, + + BodyPart.LEFT_BUST, + BodyPart.RIGHT_BUST, + BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND, BodyPart.LEFT_FOOT, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt index ae41ab08c8..fd91a76cd3 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/bust-plugin-output-encoder.kt @@ -28,7 +28,7 @@ private class BustOutputState { ): List { val messages = mutableListOf() - val chest = bones[BodyPart.CHEST] + val chest = bones[BodyPart.UPPER_CHEST] val leftBust = bones[BodyPart.LEFT_BUST] val rightBust = bones[BodyPart.RIGHT_BUST] From 391ed146576bbc08d3ecb642c1799469a2e27c11 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 9 Sep 2026 17:26:46 -0500 Subject: [PATCH 12/16] Adjust test import for bust. --- .../dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt index c12e2a9832..6a6f1b7a71 100644 --- a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt +++ b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt @@ -1,7 +1,7 @@ package dev.slimevr.skeleton import dev.slimevr.skeleton.inputprocessors.BoneDirectLinkInputProcessor -import dev.slimevr.skeleton.inputprocessors.ToeDirectLinkInputProcessor +import dev.slimevr.skeleton.inputprocessors.BustInputProcessor import io.github.axisangles.ktmath.Quaternion import org.junit.jupiter.api.Test import solarxr_protocol.datatypes.BodyPart From c02cbc1a1c6e2e3f9f8aaf047c6126d4842a4337 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Wed, 9 Sep 2026 17:40:23 -0500 Subject: [PATCH 13/16] Update bust test --- .../BustDirectLinkInputProcessorTest.kt | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt index 6a6f1b7a71..a663bf45e9 100644 --- a/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt +++ b/server/core/src/test/java/dev/slimevr/skeleton/BustDirectLinkInputProcessorTest.kt @@ -1,6 +1,5 @@ package dev.slimevr.skeleton -import dev.slimevr.skeleton.inputprocessors.BoneDirectLinkInputProcessor import dev.slimevr.skeleton.inputprocessors.BustInputProcessor import io.github.axisangles.ktmath.Quaternion import org.junit.jupiter.api.Test @@ -8,31 +7,28 @@ import solarxr_protocol.datatypes.BodyPart import kotlin.test.assertTrue class BustDirectLinkInputProcessorTest { + @Test fun `test all missing bust trackers`() { - val processor = BoneDirectLinkInputProcessor() + val processor = BustInputProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutateCopy { map -> - map[BodyPart.UPPER_CHEST] = map.getValue(BodyPart.UPPER_CHEST).copy( - rotation = Quaternion.fromRotationVector(10f, 40f, 15f), - isRotationActive = true, - ) + map[BodyPart.UPPER_CHEST] = + map.getValue(BodyPart.UPPER_CHEST).copy( + rotation = Quaternion.fromRotationVector(10f, 40f, 15f), + isRotationActive = true, + ) } - val state = SkeletonState( - boneInputs = inputs, - skeletonHeight = 1.7f, - floorLevel = 0f, - paused = false, - pausedProcessedBoneInputs = inputs, - ) - - val newInputs = processor.process(state.boneInputs, state.skeletonHeight) + processor.process(inputs, 1.7f) val leftBustIsSameRotationAsChest = - newInputs[BodyPart.LEFT_BUST]?.rotation == newInputs[BodyPart.UPPER_CHEST]?.rotation + inputs[BodyPart.LEFT_BUST]?.rotation == + inputs[BodyPart.UPPER_CHEST]?.rotation val rightBustIsSameRotationAsChest = - newInputs[BodyPart.RIGHT_BUST]?.rotation == newInputs[BodyPart.UPPER_CHEST]?.rotation + inputs[BodyPart.RIGHT_BUST]?.rotation == + inputs[BodyPart.UPPER_CHEST]?.rotation assertTrue(leftBustIsSameRotationAsChest) assertTrue(rightBustIsSameRotationAsChest) From 549cf2baf459114de4aace5205581f111f2332d7 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Thu, 10 Sep 2026 16:12:27 -0500 Subject: [PATCH 14/16] Correct variables and commentary for bust --- .../java/dev/slimevr/skeleton/proportions.kt | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 5187c31938..0a1214206c 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -128,7 +128,10 @@ fun toBoneOffsets(lengths: Map): BoneOffsets { tail.putAll(getToeOffsets(it)) head.putAll(getToeHeadOffsets(it)) } - this[SkeletonBone.CHEST]?.let { offsets.putAll(getBustOffsets(it)) } + lengths[SkeletonBone.CHEST]?.let { + tail.putAll(getBustOffsets(it)) + head.putAll(getBustHeadOffsets(it)) + } return BoneOffsets(tail, head) } @@ -303,13 +306,39 @@ private fun getToeHeadOffsets(footLength: Float): Map = build } } + +private class Bust( + val segments: Pair, + val lengthFraction: Float, + val headOffset: Vector3, +) + +private val BUST = listOf( + Bust( + BodyPart.LEFT_BUST to BodyPart.RIGHT_BUST, + lengthFraction = 0.27f, + headOffset = Vector3(0.28f, 0f, 0f), + ), +) + + /** - * Returns the offsets for the bust bones scaled from the chestLength. + * Returns the offsets for the bust bones scaled from the chest. */ -private fun getBustOffsets(bustLength: Float): BodyPartMap = - BodyPartMap( - mapOf( - BodyPart.LEFT_BUST to Vector3(0f, 0f, -bustLength * 0.2f), - BodyPart.RIGHT_BUST to Vector3(0f, 0f, -bustLength * 0.2f), - ), - ) +private fun getBustOffsets(bustLength: Float) = buildMap { + for (bust in BUST) { + val bustLength = bustLength * bust.lengthFraction + put(bust.segments.first, Vector3(0f, 0f, -bustLength * 0.2f)) + put(bust.segments.second, Vector3(0f, 0f, -bustLength * 0.2f)) + } +} + +// Head offsets spread the bust roots across the chest. X is the chest's medial-lateral axis in +// bust-local space, positive toward the left bust. Values are fractions of bustLength. +private fun getBustHeadOffsets(bustLength: Float): Map = buildMap { + for (bust in BUST) { + val k = bust.headOffset + put(bust.segments.first, Vector3(k.x * bustLength, k.y * bustLength, k.z * bustLength)) + put(bust.segments.second, Vector3(-k.x * bustLength, k.y * bustLength, k.z * bustLength)) + } +} From bf40d3a77d6a62084631e60a3e4dc3a4e56c036a Mon Sep 17 00:00:00 2001 From: Sebastina Date: Thu, 10 Sep 2026 17:05:46 -0500 Subject: [PATCH 15/16] Adjust bust proportion offsets, fix translations. --- gui/app/public/i18n/en/translation.ftl | 2 ++ server/core/src/main/java/dev/slimevr/skeleton/proportions.kt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index dcdc21b385..eb8ca0e4c6 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -64,6 +64,7 @@ body_part-NONE = Unassigned body_part-HEAD = Head body_part-NECK = Neck body_part-RIGHT_SHOULDER = Right shoulder +body_part-RIGHT_BUST = Right bust body_part-RIGHT_UPPER_ARM = Right upper arm body_part-RIGHT_LOWER_ARM = Right lower arm body_part-RIGHT_HAND = Right hand @@ -75,6 +76,7 @@ body_part-CHEST = Chest body_part-WAIST = Waist body_part-HIP = Hip body_part-LEFT_SHOULDER = Left shoulder +body_part-LEFT_BUST = Left bust body_part-LEFT_UPPER_ARM = Left upper arm body_part-LEFT_LOWER_ARM = Left lower arm body_part-LEFT_HAND = Left hand diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 0a1214206c..6f7cd0074b 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -338,7 +338,7 @@ private fun getBustOffsets(bustLength: Float) = buildMap { private fun getBustHeadOffsets(bustLength: Float): Map = buildMap { for (bust in BUST) { val k = bust.headOffset - put(bust.segments.first, Vector3(k.x * bustLength, k.y * bustLength, k.z * bustLength)) - put(bust.segments.second, Vector3(-k.x * bustLength, k.y * bustLength, k.z * bustLength)) + put(bust.segments.first, Vector3(k.x * -bustLength * 0.12f, k.y * bustLength, k.z * bustLength)) + put(bust.segments.second, Vector3(-k.x * -bustLength * 0.12f, k.y * bustLength, k.z * bustLength)) } } From 3763cb083a19b53fcddbdafe1433a5924e29db33 Mon Sep 17 00:00:00 2001 From: Sebastina Date: Thu, 10 Sep 2026 17:55:45 -0500 Subject: [PATCH 16/16] Fix merge regression --- gui/app/src/utils/skeletonParts.ts | 2 +- .../dev/slimevr/skeleton/bodypart-structure.kt | 15 ++++++++++----- .../main/java/dev/slimevr/skeleton/proportions.kt | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/gui/app/src/utils/skeletonParts.ts b/gui/app/src/utils/skeletonParts.ts index 06af6fc263..55f2d75b7b 100644 --- a/gui/app/src/utils/skeletonParts.ts +++ b/gui/app/src/utils/skeletonParts.ts @@ -287,7 +287,7 @@ export const SKELETON_PART_PRESETS: Record = { offset: inBoneLengths({ length: 0.1 }), }), { trackerOffset: WAIST_TRACKER_OFFSET } - }, + ), [BodyPart.LOWER_WAIST]: part( model('waist', { scale: spanBone({ girthFrom: 'hips', length: 1, width: 0.95, depth: 0.95 }), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index ebf5b150a7..8decc125cf 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -54,11 +54,16 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.UPPER_CHEST to arrayOf( BodyPart.LEFT_BUST, BodyPart.RIGHT_BUST, - BodyPart.CHEST), - BodyPart.CHEST to arrayOf(BodyPart.WAIST), - BodyPart.WAIST to arrayOf(BodyPart.HIP), - BodyPart.HIP to arrayOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), - + BodyPart.LOWER_CHEST), + + BodyPart.LOWER_CHEST to arrayOf(BodyPart.UPPER_WAIST), + BodyPart.UPPER_WAIST to arrayOf(BodyPart.LOWER_WAIST), + BodyPart.LOWER_WAIST to arrayOf(BodyPart.HIP), + + BodyPart.HIP to arrayOf( + BodyPart.LEFT_UPPER_LEG, + BodyPart.RIGHT_UPPER_LEG, + ), BodyPart.LEFT_UPPER_LEG to arrayOf(BodyPart.LEFT_LOWER_LEG), BodyPart.LEFT_LOWER_LEG to arrayOf(BodyPart.LEFT_FOOT), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 75f9527533..04fe58df26 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -131,7 +131,7 @@ fun toBoneOffsets(lengths: Map): BoneOffsets { tail.putAll(getToeOffsets(it)) head.putAll(getToeHeadOffsets(it)) } - lengths[SkeletonBone.CHEST]?.let { + lengths[SkeletonBone.UPPER_CHEST]?.let { tail.putAll(getBustOffsets(it)) head.putAll(getBustHeadOffsets(it)) }