Burn everything and start fresh

This commit is contained in:
loucass003
2026-03-17 11:37:27 +01:00
parent 95daec6814
commit d691619b98
258 changed files with 16 additions and 43016 deletions

View File

@@ -13,7 +13,7 @@
perSystem = { pkgs, ... }:
let
runtimeLibs = pkgs: (with pkgs; [
jdk17
jdk22
alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat
gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify
@@ -33,8 +33,8 @@
name = "slimevr-env";
targetPkgs = runtimeLibs;
profile = ''
export JAVA_HOME=${pkgs.jdk17}
export PATH="${pkgs.jdk17}/bin:$PATH"
export JAVA_HOME=${pkgs.jdk22}
export PATH="${pkgs.jdk22}/bin:$PATH"
# Tell electron-builder to use system tools instead of downloading them
export USE_SYSTEM_FPM=true

View File

@@ -22,12 +22,12 @@ plugins {
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(17))
languageVersion.set(JavaLanguageVersion.of(22))
}
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
languageVersion.set(JavaLanguageVersion.of(22))
}
}
@@ -83,7 +83,7 @@ val deleteTempKeyStore = tasks.register<Delete>("deleteTempKeyStore") {
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
jvmTarget.set(JvmTarget.JVM_22)
freeCompilerArgs.set(listOf("-Xvalue-classes"))
}
}
@@ -217,7 +217,7 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_22
targetCompatibility = JavaVersion.VERSION_22
}
}

View File

@@ -14,20 +14,19 @@ plugins {
`java-library`
}
// FIXME: Please replace these to Java 11 as that's what they actually are
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(17))
languageVersion.set(JavaLanguageVersion.of(22))
}
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
languageVersion.set(JavaLanguageVersion.of(22))
}
}
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
jvmTarget.set(JvmTarget.JVM_22)
freeCompilerArgs.set(listOf("-Xvalue-classes"))
}
}
@@ -60,17 +59,7 @@ allprojects {
dependencies {
implementation(project(":solarxr-protocol"))
// This dependency is used internally,
// and not exposed to consumers on their own compile classpath.
implementation("com.google.flatbuffers:flatbuffers-java:22.10.26")
implementation("commons-cli:commons-cli:1.11.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.21.0")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.0")
implementation("com.github.jonpeterson:jackson-module-model-versioning:1.2.2")
implementation("org.apache.commons:commons-math3:3.6.1")
implementation("org.apache.commons:commons-lang3:3.20.0")
implementation("org.apache.commons:commons-collections4:4.5.0")
implementation("com.illposed.osc:javaosc-core:0.9")
implementation("org.java-websocket:Java-WebSocket:1.+")

View File

@@ -1,96 +0,0 @@
package dev.slimevr
import com.melloware.jintellitype.HotkeyListener
import com.melloware.jintellitype.JIntellitype
import dev.slimevr.config.KeybindingsConfig
import dev.slimevr.tracking.trackers.TrackerUtils
import io.eiren.util.OperatingSystem
import io.eiren.util.OperatingSystem.Companion.currentPlatform
import io.eiren.util.ann.AWTThread
import io.eiren.util.logging.LogManager
class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener {
val config: KeybindingsConfig = server.configManager.vrConfig.keybindings
init {
if (currentPlatform != OperatingSystem.WINDOWS) {
LogManager
.info(
"[Keybinding] Currently only supported on Windows. Keybindings will be disabled.",
)
} else {
try {
if (JIntellitype.getInstance() != null) {
JIntellitype.getInstance().addHotKeyListener(this)
val fullResetBinding = config.fullResetBinding
JIntellitype.getInstance()
.registerHotKey(FULL_RESET, fullResetBinding)
LogManager.info("[Keybinding] Bound full reset to $fullResetBinding")
val yawResetBinding = config.yawResetBinding
JIntellitype.getInstance()
.registerHotKey(YAW_RESET, yawResetBinding)
LogManager.info("[Keybinding] Bound yaw reset to $yawResetBinding")
val mountingResetBinding = config.mountingResetBinding
JIntellitype.getInstance()
.registerHotKey(MOUNTING_RESET, mountingResetBinding)
LogManager.info("[Keybinding] Bound reset mounting to $mountingResetBinding")
val feetMountingResetBinding = config.feetMountingResetBinding
JIntellitype.getInstance()
.registerHotKey(FEET_MOUNTING_RESET, feetMountingResetBinding)
LogManager.info("[Keybinding] Bound feet reset mounting to $feetMountingResetBinding")
val pauseTrackingBinding = config.pauseTrackingBinding
JIntellitype.getInstance()
.registerHotKey(PAUSE_TRACKING, pauseTrackingBinding)
LogManager.info("[Keybinding] Bound pause tracking to $pauseTrackingBinding")
}
} catch (e: Throwable) {
LogManager
.warning(
"[Keybinding] JIntellitype initialization failed. Keybindings will be disabled. Try restarting your computer.",
)
}
}
}
@AWTThread
override fun onHotKey(identifier: Int) {
when (identifier) {
FULL_RESET -> server.scheduleResetTrackersFull(RESET_SOURCE_NAME, config.fullResetDelay)
YAW_RESET -> server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, config.yawResetDelay)
MOUNTING_RESET -> server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
config.mountingResetDelay,
)
FEET_MOUNTING_RESET -> server.scheduleResetTrackersMounting(
RESET_SOURCE_NAME,
config.feetMountingResetDelay,
TrackerUtils.feetsBodyParts,
)
PAUSE_TRACKING ->
server
.scheduleTogglePauseTracking(
RESET_SOURCE_NAME,
config.pauseTrackingDelay,
)
}
}
companion object {
private const val RESET_SOURCE_NAME = "Keybinding"
private const val FULL_RESET = 1
private const val YAW_RESET = 2
private const val MOUNTING_RESET = 3
private const val FEET_MOUNTING_RESET = 4
private const val PAUSE_TRACKING = 5
}
}

View File

@@ -1,59 +0,0 @@
package dev.slimevr
data class NetworkInfo(
val name: String?,
val description: String?,
val category: NetworkCategory?,
val connectivity: Set<ConnectivityFlags>?,
val connected: Boolean?,
)
/**
* @see <a href="https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_network_category">NLM_NETWORK_CATEGORY enumeration (netlistmgr.h)</a>
*/
enum class NetworkCategory(val value: Int) {
PUBLIC(0),
PRIVATE(1),
DOMAIN_AUTHENTICATED(2),
;
companion object {
fun fromInt(value: Int) = values().find { it.value == value }
}
}
/**
* @see <a href="https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_connectivity">NLM_CONNECTIVITY enumeration (netlistmgr.h)</a>
*/
enum class ConnectivityFlags(val value: Int) {
DISCONNECTED(0),
IPV4_NOTRAFFIC(0x1),
IPV6_NOTRAFFIC(0x2),
IPV4_SUBNET(0x10),
IPV4_LOCALNETWORK(0x20),
IPV4_INTERNET(0x40),
IPV6_SUBNET(0x100),
IPV6_LOCALNETWORK(0x200),
IPV6_INTERNET(0x400),
;
companion object {
fun fromInt(value: Int): Set<ConnectivityFlags> = if (value == 0) {
setOf(DISCONNECTED)
} else {
values().filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet()
}
}
}
abstract class NetworkProfileChecker {
abstract val isSupported: Boolean
abstract val publicNetworks: List<NetworkInfo>
}
class NetworkProfileCheckerStub : NetworkProfileChecker() {
override val isSupported: Boolean
get() = false
override val publicNetworks: List<NetworkInfo>
get() = listOf()
}

View File

@@ -1,8 +0,0 @@
package dev.slimevr;
public enum NetworkProtocol {
OWO_LEGACY,
SLIMEVR_RAW,
SLIMEVR_FLATBUFFER,
SLIMEVR_WEBSOCKET
}

View File

@@ -1,487 +1,4 @@
package dev.slimevr
import com.jme3.system.NanoTimer
import dev.slimevr.autobone.AutoBoneHandler
import dev.slimevr.bridge.Bridge
import dev.slimevr.bridge.ISteamVRBridge
import dev.slimevr.config.ConfigManager
import dev.slimevr.firmware.FirmwareUpdateHandler
import dev.slimevr.firmware.SerialFlashingHandler
import dev.slimevr.games.vrchat.VRCConfigHandler
import dev.slimevr.games.vrchat.VRCConfigHandlerStub
import dev.slimevr.games.vrchat.VRChatConfigManager
import dev.slimevr.guards.ServerGuards
import dev.slimevr.osc.OSCHandler
import dev.slimevr.osc.OSCRouter
import dev.slimevr.osc.VMCHandler
import dev.slimevr.osc.VRCOSCHandler
import dev.slimevr.posestreamer.BVHRecorder
import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler
import dev.slimevr.reset.ResetHandler
import dev.slimevr.reset.ResetTimerManager
import dev.slimevr.reset.resetTimer
import dev.slimevr.serial.ProvisioningHandler
import dev.slimevr.serial.SerialHandler
import dev.slimevr.serial.SerialHandlerStub
import dev.slimevr.setup.HandshakeHandler
import dev.slimevr.setup.TapSetupHandler
import dev.slimevr.status.StatusSystem
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import dev.slimevr.tracking.trackers.*
import dev.slimevr.tracking.trackers.udp.TrackersUDPServer
import dev.slimevr.trackingchecklist.TrackingChecklistManager
import dev.slimevr.util.ann.VRServerThread
import dev.slimevr.websocketapi.WebSocketVRBridge
import io.eiren.util.ann.ThreadSafe
import io.eiren.util.ann.ThreadSecure
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import solarxr_protocol.datatypes.TrackerIdT
import solarxr_protocol.rpc.ResetType
import java.util.*
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Consumer
import kotlin.collections.ArrayList
import kotlin.concurrent.schedule
typealias BridgeProvider = (
server: VRServer,
computedTrackers: List<Tracker>,
) -> Sequence<Bridge>
const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
class VRServer @JvmOverloads constructor(
bridgeProvider: BridgeProvider = { _, _ -> sequence {} },
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() },
networkProfileProvider: (VRServer) -> NetworkProfileChecker = { _ -> NetworkProfileCheckerStub() },
acquireMulticastLock: () -> Any? = { null },
@JvmField val configManager: ConfigManager,
) : Thread("VRServer") {
@JvmField
val humanPoseManager: HumanPoseManager
private val trackers: MutableList<Tracker> = FastList()
val trackersServer: TrackersUDPServer
private val bridges: MutableList<Bridge> = FastList()
private val tasks: Queue<Runnable> = LinkedBlockingQueue()
private val newTrackersConsumers: MutableList<Consumer<Tracker>> = FastList()
private val trackerStatusListeners: MutableList<TrackerStatusListener> = FastList()
private val onTick: MutableList<Runnable> = FastList()
private val lock = acquireMulticastLock()
val oSCRouter: OSCRouter
@JvmField
val vrcOSCHandler: VRCOSCHandler
val vMCHandler: VMCHandler
@JvmField
val deviceManager: DeviceManager
@JvmField
val bvhRecorder: BVHRecorder
@JvmField
val serialHandler: SerialHandler
var serialFlashingHandler: SerialFlashingHandler?
val firmwareUpdateHandler: FirmwareUpdateHandler
val vrcConfigManager: VRChatConfigManager
@JvmField
val autoBoneHandler: AutoBoneHandler
@JvmField
val tapSetupHandler: TapSetupHandler
@JvmField
val protocolAPI: ProtocolAPI
private val timer = Timer()
private val resetTimerManager = ResetTimerManager()
val fpsTimer = NanoTimer()
@JvmField
val provisioningHandler: ProvisioningHandler
@JvmField
val resetHandler: ResetHandler
@JvmField
val statusSystem = StatusSystem()
@JvmField
val handshakeHandler = HandshakeHandler()
val trackingChecklistManager: TrackingChecklistManager
val networkProfileChecker: NetworkProfileChecker
val serverGuards = ServerGuards()
init {
// UwU
deviceManager = DeviceManager(this)
serialHandler = serialHandlerProvider(this)
serialFlashingHandler = flashingHandlerProvider(this)
provisioningHandler = ProvisioningHandler(this)
resetHandler = ResetHandler()
tapSetupHandler = TapSetupHandler()
humanPoseManager = HumanPoseManager(this)
// AutoBone requires HumanPoseManager first
autoBoneHandler = AutoBoneHandler(this)
firmwareUpdateHandler = FirmwareUpdateHandler(this)
vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this))
networkProfileChecker = networkProfileProvider(this)
trackingChecklistManager = TrackingChecklistManager(this)
protocolAPI = ProtocolAPI(this)
val computedTrackers = humanPoseManager.computedTrackers
// Start server for SlimeVR trackers
val trackerPort = configManager.vrConfig.server.trackerPort
LogManager.info("Starting the tracker server on port $trackerPort...")
trackersServer = TrackersUDPServer(
trackerPort,
"Sensors UDP server",
) { tracker: Tracker -> registerTracker(tracker) }
// Start bridges and WebSocket server
for (bridge in bridgeProvider(this, computedTrackers) + sequenceOf(WebSocketVRBridge(computedTrackers, this))) {
tasks.add(Runnable { bridge.startBridge() })
bridges.add(bridge)
}
// Initialize OSC handlers
vrcOSCHandler = VRCOSCHandler(
this,
configManager.vrConfig.vrcOSC,
computedTrackers,
)
vMCHandler = VMCHandler(
this,
humanPoseManager,
configManager.vrConfig.vmc,
)
// Initialize OSC router
val oscHandlers = FastList<OSCHandler>()
oscHandlers.add(vrcOSCHandler)
oscHandlers.add(vMCHandler)
oSCRouter = OSCRouter(configManager.vrConfig.oscRouter, oscHandlers)
bvhRecorder = BVHRecorder(this)
for (tracker in computedTrackers) {
registerTracker(tracker)
}
instance = this
}
fun hasBridge(bridgeClass: Class<out Bridge?>): Boolean {
for (bridge in bridges) {
if (bridgeClass.isAssignableFrom(bridge.javaClass)) {
return true
}
}
return false
}
// FIXME: Code using this function normally uses this to get the SteamVR driver but
// that's because we first save the SteamVR driver bridge and then the feeder in the array.
// Not really a great thing to have.
@ThreadSafe
fun <E : Bridge?> getVRBridge(bridgeClass: Class<E>): E? {
for (bridge in bridges) {
if (bridgeClass.isAssignableFrom(bridge.javaClass)) {
return bridgeClass.cast(bridge)
}
}
return null
}
fun addOnTick(runnable: Runnable) {
onTick.add(runnable)
}
@ThreadSafe
fun addNewTrackerConsumer(consumer: Consumer<Tracker>) {
queueTask {
newTrackersConsumers.add(consumer)
for (tracker in trackers) {
consumer.accept(tracker)
}
}
}
@ThreadSafe
fun trackerUpdated(tracker: Tracker?) {
queueTask {
humanPoseManager.trackerUpdated(tracker)
updateSkeletonModel()
refreshTrackersDriftCompensationEnabled()
configManager.vrConfig.writeTrackerConfig(tracker)
configManager.saveConfig()
}
}
@ThreadSafe
fun addSkeletonUpdatedCallback(consumer: Consumer<HumanSkeleton>) {
queueTask { humanPoseManager.addSkeletonUpdatedCallback(consumer) }
}
@VRServerThread
override fun run() {
trackersServer.start()
while (true) {
// final long start = System.currentTimeMillis();
fpsTimer.update()
do {
val task = tasks.poll() ?: break
task.run()
} while (true)
for (task in onTick) {
task.run()
}
for (bridge in bridges) {
bridge.dataRead()
}
for (tracker in trackers) {
tracker.tick(fpsTimer.timePerFrame)
}
humanPoseManager.update()
for (bridge in bridges) {
bridge.dataWrite()
}
vrcOSCHandler.update()
vMCHandler.update()
// final long time = System.currentTimeMillis() - start;
try {
sleep(1) // 1000Hz
} catch (error: InterruptedException) {
LogManager.info("VRServer thread interrupted")
break
}
}
}
@ThreadSafe
fun queueTask(r: Runnable) {
tasks.add(r)
}
@VRServerThread
private fun trackerAdded(tracker: Tracker) {
humanPoseManager.trackerAdded(tracker)
updateSkeletonModel()
if (tracker.isComputed) {
vMCHandler.addComputedTracker(tracker)
}
refreshTrackersDriftCompensationEnabled()
}
@ThreadSecure
fun registerTracker(tracker: Tracker) {
configManager.vrConfig.readTrackerConfig(tracker)
queueTask {
trackers.add(tracker)
trackerAdded(tracker)
for (tc in newTrackersConsumers) {
tc.accept(tracker)
}
}
}
@ThreadSafe
fun updateSkeletonModel() {
queueTask {
humanPoseManager.updateSkeletonModelFromServer()
vrcOSCHandler.setHeadTracker(TrackerUtils.getTrackerForSkeleton(trackers, TrackerPosition.HEAD))
if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) {
RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler)
}
}
}
fun resetTrackersFull(resetSourceName: String?, bodyParts: List<Int> = ArrayList()) {
queueTask { humanPoseManager.resetTrackersFull(resetSourceName, bodyParts) }
}
fun resetTrackersYaw(resetSourceName: String?, bodyParts: List<Int> = TrackerUtils.allBodyPartsButFingers) {
queueTask { humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts) }
}
fun resetTrackersMounting(resetSourceName: String?, bodyParts: List<Int>? = null) {
queueTask { humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts) }
}
fun clearTrackersMounting(resetSourceName: String?) {
queueTask { humanPoseManager.clearTrackersMounting(resetSourceName) }
}
fun getPauseTracking(): Boolean = humanPoseManager.getPauseTracking()
fun setPauseTracking(pauseTracking: Boolean, sourceName: String?) {
queueTask {
humanPoseManager.setPauseTracking(pauseTracking, sourceName)
// Toggle trackers as they don't toggle when tracking is paused
if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) {
RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler)
}
}
}
fun togglePauseTracking(sourceName: String?) {
queueTask {
humanPoseManager.togglePauseTracking(sourceName)
// Toggle trackers as they don't toggle when tracking is paused
if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) {
RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler)
}
}
}
fun scheduleResetTrackersFull(resetSourceName: String?, delay: Long, bodyParts: List<Int> = ArrayList()) {
resetTimer(
resetTimerManager,
delay,
onTick = { progress ->
resetHandler.sendStarted(ResetType.Full, bodyParts, progress, delay.toInt())
},
onComplete = {
queueTask {
humanPoseManager.resetTrackersFull(resetSourceName, bodyParts)
resetHandler.sendFinished(ResetType.Full, bodyParts, delay.toInt())
}
},
)
}
fun scheduleResetTrackersYaw(resetSourceName: String?, delay: Long, bodyParts: List<Int> = TrackerUtils.allBodyPartsButFingers) {
resetTimer(
resetTimerManager,
delay,
onTick = { progress ->
resetHandler.sendStarted(ResetType.Yaw, bodyParts, progress, delay.toInt())
},
onComplete = {
queueTask {
humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts)
resetHandler.sendFinished(ResetType.Yaw, bodyParts, delay.toInt())
}
},
)
}
fun scheduleResetTrackersMounting(resetSourceName: String?, delay: Long, bodyParts: List<Int>? = null) {
resetTimer(
resetTimerManager,
delay,
onTick = { progress ->
resetHandler.sendStarted(ResetType.Mounting, bodyParts, progress, delay.toInt())
},
onComplete = {
queueTask {
humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts)
resetHandler.sendFinished(ResetType.Mounting, bodyParts, delay.toInt())
}
},
)
}
fun scheduleSetPauseTracking(pauseTracking: Boolean, sourceName: String?, delay: Long) {
timer.schedule(delay) {
queueTask { humanPoseManager.setPauseTracking(pauseTracking, sourceName) }
}
}
fun scheduleTogglePauseTracking(sourceName: String?, delay: Long) {
timer.schedule(delay) {
queueTask { humanPoseManager.togglePauseTracking(sourceName) }
}
}
fun setLegTweaksEnabled(value: Boolean) {
queueTask { humanPoseManager.setLegTweaksEnabled(value) }
}
fun setSkatingReductionEnabled(value: Boolean) {
queueTask { humanPoseManager.setSkatingCorrectionEnabled(value) }
}
fun setFloorClipEnabled(value: Boolean) {
queueTask { humanPoseManager.setFloorClipEnabled(value) }
}
val trackersCount: Int
get() = trackers.size
val allTrackers: List<Tracker>
get() = FastList(trackers)
fun getTrackerById(id: TrackerIdT): Tracker? {
for (tracker in trackers) {
if (tracker.trackerNum != id.trackerNum) {
continue
}
// Handle synthetic devices
if (id.deviceId == null && tracker.device == null) {
return tracker
}
if (tracker.device != null && id.deviceId != null && id.deviceId.id == tracker.device.id) {
// This is a physical tracker, and both device id and the
// tracker num match
return tracker
}
}
return null
}
fun clearTrackersDriftCompensation() {
for (t in allTrackers) {
if (t.isImu()) {
t.resetsHandler.clearDriftCompensation()
}
}
}
fun refreshTrackersDriftCompensationEnabled() {
for (t in allTrackers) {
if (t.isImu()) {
t.resetsHandler.refreshDriftCompensationEnabled()
}
}
}
fun trackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus) {
trackerStatusListeners.forEach { it.onTrackerStatusChanged(tracker, oldStatus, newStatus) }
}
fun addTrackerStatusListener(listener: TrackerStatusListener) {
trackerStatusListeners.add(listener)
}
fun removeTrackerStatusListener(listener: TrackerStatusListener) {
trackerStatusListeners.removeIf { listener == it }
}
companion object {
private val nextLocalTrackerId = AtomicInteger()
lateinit var instance: VRServer
private set
val instanceInitialized: Boolean
get() = ::instance.isInitialized
@JvmStatic
fun getNextLocalTrackerId(): Int = nextLocalTrackerId.incrementAndGet()
@JvmStatic
val currentLocalTrackerId: Int
get() = nextLocalTrackerId.get()
}
class VRServer {
}

View File

@@ -1,698 +0,0 @@
package dev.slimevr.autobone
import dev.slimevr.SLIMEVR_IDENTIFIER
import dev.slimevr.VRServer
import dev.slimevr.autobone.errors.*
import dev.slimevr.config.AutoBoneConfig
import dev.slimevr.config.SkeletonConfig
import dev.slimevr.poseframeformat.PfrIO
import dev.slimevr.poseframeformat.PfsIO
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import dev.slimevr.tracking.trackers.TrackerRole
import io.eiren.util.OperatingSystem
import io.eiren.util.StringUtils
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import io.github.axisangles.ktmath.Vector3
import org.apache.commons.lang3.tuple.Pair
import java.io.File
import java.util.*
import java.util.function.Consumer
import java.util.function.Function
import kotlin.math.*
class AutoBone(private val server: VRServer) {
// This is filled by loadConfigValues()
val offsets = EnumMap<SkeletonConfigOffsets, Float>(
SkeletonConfigOffsets::class.java,
)
val adjustOffsets = FastList(
arrayOf(
SkeletonConfigOffsets.HEAD,
SkeletonConfigOffsets.NECK,
SkeletonConfigOffsets.UPPER_CHEST,
SkeletonConfigOffsets.CHEST,
SkeletonConfigOffsets.WAIST,
SkeletonConfigOffsets.HIP,
// HIPS_WIDTH now works when using body proportion error! It's not the
// best still, but it is somewhat functional
SkeletonConfigOffsets.HIPS_WIDTH,
SkeletonConfigOffsets.UPPER_LEG,
SkeletonConfigOffsets.LOWER_LEG,
),
)
var estimatedHeight: Float = 1f
// The total height of the normalized adjusted offsets
var adjustedHeightNormalized: Float = 1f
// #region Error functions
var slideError = SlideError()
var offsetSlideError = OffsetSlideError()
var footHeightOffsetError = FootHeightOffsetError()
var bodyProportionError = BodyProportionError()
var heightError = HeightError()
var positionError = PositionError()
var positionOffsetError = PositionOffsetError()
// #endregion
val globalConfig: AutoBoneConfig = server.configManager.vrConfig.autoBone
val globalSkeletonConfig: SkeletonConfig = server.configManager.vrConfig.skeleton
init {
loadConfigValues()
}
private fun loadConfigValues() {
// Remove all previous values
offsets.clear()
// Get current or default skeleton configs
val skeleton = server.humanPoseManager
// Still compensate for a null skeleton, as it may not be initialized yet
val getOffset: Function<SkeletonConfigOffsets, Float> =
if (skeleton != null) {
Function { key: SkeletonConfigOffsets -> skeleton.getOffset(key) }
} else {
val defaultConfig = SkeletonConfigManager(false)
Function { config: SkeletonConfigOffsets ->
defaultConfig.getOffset(config)
}
}
for (bone in adjustOffsets) {
val offset = getOffset.apply(bone)
if (offset > 0f) {
offsets[bone] = offset
}
}
}
fun applyConfig(
humanPoseManager: HumanPoseManager,
offsets: Map<SkeletonConfigOffsets, Float> = this.offsets,
) {
for ((offset, value) in offsets) {
humanPoseManager.setOffset(offset, value)
}
}
@JvmOverloads
fun applyAndSaveConfig(humanPoseManager: HumanPoseManager? = this.server.humanPoseManager): Boolean {
if (humanPoseManager == null) return false
applyConfig(humanPoseManager)
humanPoseManager.saveConfig()
server.configManager.saveConfig()
LogManager.info("[AutoBone] Configured skeleton bone lengths")
return true
}
fun calcTargetHmdHeight(
frames: PoseFrames,
config: AutoBoneConfig = globalConfig,
): Float {
val targetHeight: Float
// Get the current skeleton from the server
val humanPoseManager = server.humanPoseManager
// Still compensate for a null skeleton, as it may not be initialized yet
@Suppress("SENSELESS_COMPARISON")
if (config.useSkeletonHeight && humanPoseManager != null) {
// If there is a skeleton available, calculate the target height
// from its configs
targetHeight = humanPoseManager.userHeightFromConfig
LogManager
.warning(
"[AutoBone] Target height loaded from skeleton (Make sure you reset before running!): $targetHeight",
)
} else {
// Otherwise if there is no skeleton available, attempt to get the
// max HMD height from the recording
val hmdHeight = frames.maxHmdHeight
if (hmdHeight <= MIN_HEIGHT) {
LogManager
.warning(
"[AutoBone] Max headset height detected (Value seems too low, did you not stand up straight while measuring?): $hmdHeight",
)
} else {
LogManager.info("[AutoBone] Max headset height detected: $hmdHeight")
}
// Estimate target height from HMD height
targetHeight = hmdHeight
}
return targetHeight
}
private fun updateRecordingScale(step: PoseFrameStep<AutoBoneStep>, scale: Float) {
step.framePlayer1.setScales(scale)
step.framePlayer2.setScales(scale)
step.skeleton1.update()
step.skeleton2.update()
}
fun filterFrames(frames: PoseFrames, step: PoseFrameStep<AutoBoneStep>) {
// Calculate the initial frame errors and recording stats
val frameErrors = FloatArray(frames.maxFrameCount)
val frameStats = StatsCalculator()
val recordingStats = StatsCalculator()
for (i in 0 until frames.maxFrameCount) {
frameStats.reset()
for (j in 0 until frames.maxFrameCount) {
if (i == j) continue
step.setCursors(
i,
j,
updatePlayerCursors = true,
)
frameStats.addValue(getErrorDeriv(step))
}
frameErrors[i] = frameStats.mean
recordingStats.addValue(frameStats.mean)
// LogManager.info("[AutoBone] Frame: ${i + 1}, Mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})")
}
LogManager.info("[AutoBone] Full recording mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})")
// Remove outlier frames
val sdMult = 1.4f
val mean = recordingStats.mean
val sd = recordingStats.standardDeviation * sdMult
for (i in frameErrors.size - 1 downTo 0) {
val err = frameErrors[i]
if (err < mean - sd || err > mean + sd) {
for (frameHolder in frames.frameHolders) {
frameHolder.frames.removeAt(i)
}
}
}
step.maxFrameCount = frames.maxFrameCount
// Calculate and print the resulting recording stats
recordingStats.reset()
for (i in 0 until frames.maxFrameCount) {
frameStats.reset()
for (j in 0 until frames.maxFrameCount) {
if (i == j) continue
step.setCursors(
i,
j,
updatePlayerCursors = true,
)
frameStats.addValue(getErrorDeriv(step))
}
recordingStats.addValue(frameStats.mean)
}
LogManager.info("[AutoBone] Full recording after mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})")
}
@Throws(AutoBoneException::class)
fun processFrames(
frames: PoseFrames,
config: AutoBoneConfig = globalConfig,
skeletonConfig: SkeletonConfig = globalSkeletonConfig,
epochCallback: Consumer<Epoch>? = null,
): AutoBoneResults {
check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
check(frames.maxFrameCount > 0) { "Recording has no frames." }
// Load current values for adjustable configs
loadConfigValues()
// Set the target heights either from config or calculate them
val targetHmdHeight = if (skeletonConfig.userHeight > MIN_HEIGHT) {
skeletonConfig.userHeight
} else {
calcTargetHmdHeight(frames, config)
}
check(targetHmdHeight > MIN_HEIGHT) { "Configured height ($targetHmdHeight) is too small (<= $MIN_HEIGHT)." }
// Set up the current state, making all required players and setting up the
// skeletons appropriately
val step = PoseFrameStep<AutoBoneStep>(
config = config,
serverConfig = server.configManager,
frames = frames,
preEpoch = { step ->
// Set the current adjust rate based on the current epoch
step.data.adjustRate = decayFunc(step.config.initialAdjustRate, step.config.adjustRateDecay, step.epoch)
},
onStep = this::step,
postEpoch = { step -> epoch(step, epochCallback) },
randomSeed = config.randSeed,
data = AutoBoneStep(
targetHmdHeight = targetHmdHeight,
adjustRate = 1f,
),
)
// Normalize the skeletons and get the normalized height for adjusted offsets
scaleSkeleton(step.skeleton1)
scaleSkeleton(step.skeleton2)
adjustedHeightNormalized = sumAdjustedHeightOffsets(step.skeleton1)
// Normalize offsets based on the initial normalized skeleton
scaleOffsets()
// Apply the initial normalized config values
applyConfig(step.skeleton1)
applyConfig(step.skeleton2)
// Initialize normalization to the set target height (also updates skeleton)
estimatedHeight = targetHmdHeight
updateRecordingScale(step, 1f / targetHmdHeight)
if (config.useFrameFiltering) {
filterFrames(frames, step)
}
// Iterate frames now that it's set up
PoseFrameIterator.iterateFrames(step)
// Scale the normalized offsets to the estimated height for the final result
for (entry in offsets.entries) {
entry.setValue(entry.value * estimatedHeight)
}
LogManager
.info(
"[AutoBone] Target height: ${step.data.targetHmdHeight}, Final height: $estimatedHeight",
)
if (step.data.errorStats.mean > config.maxFinalError) {
throw AutoBoneException("The final epoch error value (${step.data.errorStats.mean}) has exceeded the maximum allowed value (${config.maxFinalError}).")
}
return AutoBoneResults(
estimatedHeight,
step.data.targetHmdHeight,
offsets,
)
}
private fun epoch(
step: PoseFrameStep<AutoBoneStep>,
epochCallback: Consumer<Epoch>? = null,
) {
val config = step.config
val epoch = step.epoch
// Calculate average error over the epoch
if (epoch <= 0 || epoch >= config.numEpochs - 1 || (epoch + 1) % config.printEveryNumEpochs == 0) {
LogManager
.info(
"[AutoBone] Epoch: ${epoch + 1}, Mean error: ${step.data.errorStats.mean} (SD ${step.data.errorStats.standardDeviation}), Adjust rate: ${step.data.adjustRate}",
)
LogManager
.info(
"[AutoBone] Target height: ${step.data.targetHmdHeight}, Estimated height: $estimatedHeight",
)
}
if (epochCallback != null) {
// Scale the normalized offsets to the estimated height for the callback
val scaledOffsets = EnumMap(offsets)
for (entry in scaledOffsets.entries) {
entry.setValue(entry.value * estimatedHeight)
}
epochCallback.accept(Epoch(epoch + 1, config.numEpochs, step.data.errorStats, scaledOffsets))
}
}
private fun step(step: PoseFrameStep<AutoBoneStep>) {
// Pull frequently used variables out of trainingStep to reduce call length
val skeleton1 = step.skeleton1
val skeleton2 = step.skeleton2
// Scaling each step used to mean enforcing the target height, so keep that
// behaviour to retain predictability
if (!step.config.scaleEachStep) {
// Try to estimate a new height by calculating the height with the lowest
// error between adding or subtracting from the height
val maxHeight = step.data.targetHmdHeight + 0.2f
val minHeight = step.data.targetHmdHeight - 0.2f
step.data.hmdHeight = estimatedHeight
val heightErrorDeriv = getErrorDeriv(step)
val heightAdjust = errorFunc(heightErrorDeriv) * step.data.adjustRate
val negHeight = (estimatedHeight - heightAdjust).coerceIn(minHeight, maxHeight)
updateRecordingScale(step, 1f / negHeight)
step.data.hmdHeight = negHeight
val negHeightErrorDeriv = getErrorDeriv(step)
val posHeight = (estimatedHeight + heightAdjust).coerceIn(minHeight, maxHeight)
updateRecordingScale(step, 1f / posHeight)
step.data.hmdHeight = posHeight
val posHeightErrorDeriv = getErrorDeriv(step)
if (negHeightErrorDeriv < heightErrorDeriv && negHeightErrorDeriv < posHeightErrorDeriv) {
estimatedHeight = negHeight
// Apply the negative height scale
updateRecordingScale(step, 1f / negHeight)
} else if (posHeightErrorDeriv < heightErrorDeriv) {
estimatedHeight = posHeight
// The last estimated height set was the positive adjustment, so no need to apply it again
} else {
// Reset to the initial scale
updateRecordingScale(step, 1f / estimatedHeight)
}
}
// Update the heights used for error calculations
step.data.hmdHeight = estimatedHeight
val errorDeriv = getErrorDeriv(step)
val error = errorFunc(errorDeriv)
// In case of fire
if (java.lang.Float.isNaN(error) || java.lang.Float.isInfinite(error)) {
// Extinguish
LogManager
.warning(
"[AutoBone] Error value is invalid, resetting variables to recover",
)
// Reset adjustable config values
loadConfigValues()
// Reset error sum values
step.data.errorStats.reset()
// Continue on new data
return
}
// Store the error count for logging purposes
step.data.errorStats.addValue(errorDeriv)
val adjustVal = error * step.data.adjustRate
// If there is no adjustment whatsoever, skip this
if (adjustVal == 0f) {
return
}
val slideL = skeleton2.getComputedTracker(TrackerRole.LEFT_FOOT).position -
skeleton1.getComputedTracker(TrackerRole.LEFT_FOOT).position
val slideLLen = slideL.len()
val slideLUnit: Vector3? = if (slideLLen > MIN_SLIDE_DIST) slideL / slideLLen else null
val slideR = skeleton2.getComputedTracker(TrackerRole.RIGHT_FOOT).position -
skeleton1.getComputedTracker(TrackerRole.RIGHT_FOOT).position
val slideRLen = slideR.len()
val slideRUnit: Vector3? = if (slideRLen > MIN_SLIDE_DIST) slideR / slideRLen else null
val intermediateOffsets = EnumMap(offsets)
for (entry in intermediateOffsets.entries) {
// Skip adjustment if the epoch is before starting (for logging only) or
// if there are no BoneTypes for this value
if (step.epoch < 0 || entry.key.affectedOffsets.isEmpty()) {
break
}
val originalLength = entry.value
// Calculate the total effect of the bone based on change in rotation
val slideDot = BoneContribution.getSlideDot(
skeleton1,
skeleton2,
entry.key,
slideLUnit,
slideRUnit,
)
val dotLength = originalLength * slideDot
// Scale by the total effect of the bone
val curAdjustVal = adjustVal * -dotLength
if (curAdjustVal == 0f) {
continue
}
val newLength = originalLength + curAdjustVal
// No small or negative numbers!!! Bad algorithm!
if (newLength < 0.01f) {
continue
}
// Apply new offset length
skeleton1.setOffset(entry.key, newLength)
skeleton2.setOffset(entry.key, newLength)
scaleSkeleton(skeleton1, onlyAdjustedHeight = true)
scaleSkeleton(skeleton2, onlyAdjustedHeight = true)
// Update the skeleton poses for the new offset length
skeleton1.update()
skeleton2.update()
val newErrorDeriv = getErrorDeriv(step)
if (newErrorDeriv < errorDeriv) {
// Apply the adjusted length to the current adjusted offsets
entry.setValue(newLength)
}
// Reset the skeleton values to minimize bias in other variables, it's applied later
applyConfig(skeleton1)
applyConfig(skeleton2)
}
// Update the offsets from the adjusted ones
offsets.putAll(intermediateOffsets)
// Normalize the scale, it will be upscaled to the target height later
// We only need to scale height offsets, as other offsets are not affected by height
scaleOffsets(onlyHeightOffsets = true)
// Apply the normalized offsets to the skeleton
applyConfig(skeleton1)
applyConfig(skeleton2)
}
/**
* Sums only the adjusted height offsets of the provided HumanPoseManager
*/
private fun sumAdjustedHeightOffsets(humanPoseManager: HumanPoseManager): Float {
var sum = 0f
SkeletonConfigManager.HEIGHT_OFFSETS.forEach {
if (!adjustOffsets.contains(it)) return@forEach
sum += humanPoseManager.getOffset(it)
}
return sum
}
/**
* Sums only the height offsets of the provided offset map
*/
private fun sumHeightOffsets(offsets: EnumMap<SkeletonConfigOffsets, Float> = this.offsets): Float {
var sum = 0f
SkeletonConfigManager.HEIGHT_OFFSETS.forEach {
sum += offsets[it] ?: return@forEach
}
return sum
}
private fun scaleSkeleton(humanPoseManager: HumanPoseManager, targetHeight: Float = 1f, onlyAdjustedHeight: Boolean = false) {
// Get the scale to apply for the appropriate offsets
val scale = if (onlyAdjustedHeight) {
// Only adjusted height offsets
val adjHeight = sumAdjustedHeightOffsets(humanPoseManager)
// Remove the constant from the target, leaving only the target for adjusted height offsets
val adjTarget = targetHeight - (humanPoseManager.userHeightFromConfig - adjHeight)
// Return only the scale for adjusted offsets
adjTarget / adjHeight
} else {
targetHeight / humanPoseManager.userHeightFromConfig
}
val offsets = if (onlyAdjustedHeight) SkeletonConfigManager.HEIGHT_OFFSETS else SkeletonConfigOffsets.values
for (offset in offsets) {
if (onlyAdjustedHeight && !adjustOffsets.contains(offset)) continue
humanPoseManager.setOffset(offset, humanPoseManager.getOffset(offset) * scale)
}
}
private fun scaleOffsets(offsets: EnumMap<SkeletonConfigOffsets, Float> = this.offsets, targetHeight: Float = adjustedHeightNormalized, onlyHeightOffsets: Boolean = false) {
// Get the scale to apply for the appropriate offsets
val scale = targetHeight / sumHeightOffsets(offsets)
for (entry in offsets.entries) {
if (onlyHeightOffsets && !SkeletonConfigManager.HEIGHT_OFFSETS.contains(entry.key)) continue
entry.setValue(entry.value * scale)
}
}
@Throws(AutoBoneException::class)
private fun getErrorDeriv(step: PoseFrameStep<AutoBoneStep>): Float {
val config = step.config
var sumError = 0f
if (config.slideErrorFactor > 0f) {
sumError += slideError.getStepError(step) * config.slideErrorFactor
}
if (config.offsetSlideErrorFactor > 0f) {
sumError += (
offsetSlideError.getStepError(step) *
config.offsetSlideErrorFactor
)
}
if (config.footHeightOffsetErrorFactor > 0f) {
sumError += (
footHeightOffsetError.getStepError(step) *
config.footHeightOffsetErrorFactor
)
}
if (config.bodyProportionErrorFactor > 0f) {
sumError += (
bodyProportionError.getStepError(step) *
config.bodyProportionErrorFactor
)
}
if (config.heightErrorFactor > 0f) {
sumError += heightError.getStepError(step) * config.heightErrorFactor
}
if (config.positionErrorFactor > 0f) {
sumError += (
positionError.getStepError(step) *
config.positionErrorFactor
)
}
if (config.positionOffsetErrorFactor > 0f) {
sumError += (
positionOffsetError.getStepError(step) *
config.positionOffsetErrorFactor
)
}
return sumError
}
val lengthsString: String
get() {
val configInfo = StringBuilder()
offsets.forEach { (key, value) ->
if (configInfo.isNotEmpty()) {
configInfo.append(", ")
}
configInfo
.append(key.configKey)
.append(": ")
.append(StringUtils.prettyNumber(value * 100f, 2))
}
return configInfo.toString()
}
fun saveRecording(frames: PoseFrames, recordingFile: File) {
if (saveDir.isDirectory || saveDir.mkdirs()) {
LogManager
.info("[AutoBone] Exporting frames to \"${recordingFile.path}\"...")
if (PfsIO.tryWriteToFile(recordingFile, frames)) {
LogManager
.info(
"[AutoBone] Done exporting! Recording can be found at \"${recordingFile.path}\".",
)
} else {
LogManager
.severe(
"[AutoBone] Failed to export the recording to \"${recordingFile.path}\".",
)
}
} else {
LogManager
.severe(
"[AutoBone] Failed to create the recording directory \"${saveDir.path}\".",
)
}
}
fun saveRecording(frames: PoseFrames, recordingFileName: String) {
saveRecording(frames, File(saveDir, recordingFileName))
}
fun saveRecording(frames: PoseFrames) {
var recordingFile: File
var recordingIndex = 1
do {
recordingFile = File(saveDir, "ABRecording${recordingIndex++}.pfs")
} while (recordingFile.exists())
saveRecording(frames, recordingFile)
}
fun loadRecordings(): FastList<Pair<String, PoseFrames>> {
val recordings = FastList<Pair<String, PoseFrames>>()
loadDir.listFiles()?.forEach { file ->
if (!file.isFile) return@forEach
val frames = if (file.name.endsWith(".pfs", ignoreCase = true)) {
LogManager.info("[AutoBone] Loading PFS recording from \"${file.path}\"...")
PfsIO.tryReadFromFile(file)
} else if (file.name.endsWith(".pfr", ignoreCase = true)) {
LogManager.info("[AutoBone] Loading PFR recording from \"${file.path}\"...")
PfrIO.tryReadFromFile(file)
} else {
return@forEach
}
if (frames == null) {
LogManager.severe("[AutoBone] Failed to load recording from \"${file.path}\".")
} else {
recordings.add(Pair.of(file.name, frames))
LogManager.info("[AutoBone] Loaded recording from \"${file.path}\".")
}
}
return recordings
}
inner class Epoch(
val epoch: Int,
val totalEpochs: Int,
val epochError: StatsCalculator,
val configValues: EnumMap<SkeletonConfigOffsets, Float>,
) {
override fun toString(): String = "Epoch: $epoch, Epoch error: $epochError"
}
inner class AutoBoneResults(
val finalHeight: Float,
val targetHeight: Float,
val configValues: EnumMap<SkeletonConfigOffsets, Float>,
) {
val heightDifference: Float
get() = abs(targetHeight - finalHeight)
}
companion object {
const val MIN_HEIGHT = 0.4f
const val MIN_SLIDE_DIST = 0.002f
const val AUTOBONE_FOLDER = "AutoBone Recordings"
const val LOADAUTOBONE_FOLDER = "Load AutoBone Recordings"
// FIXME: Won't work on iOS and Android, maybe fix resolveConfigDirectory more than this
val saveDir = File(
OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve(
AUTOBONE_FOLDER,
)?.toString() ?: AUTOBONE_FOLDER,
)
val loadDir = File(
OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve(
LOADAUTOBONE_FOLDER,
)?.toString() ?: LOADAUTOBONE_FOLDER,
)
// Mean square error function
private fun errorFunc(errorDeriv: Float): Float = 0.5f * (errorDeriv * errorDeriv)
private fun decayFunc(initialAdjustRate: Float, adjustRateDecay: Float, epoch: Int): Float = if (epoch >= 0) initialAdjustRate / (1 + (adjustRateDecay * epoch)) else 0.0f
val SYMM_CONFIGS = arrayOf(
SkeletonConfigOffsets.HIPS_WIDTH,
SkeletonConfigOffsets.SHOULDERS_WIDTH,
SkeletonConfigOffsets.SHOULDERS_DISTANCE,
SkeletonConfigOffsets.UPPER_ARM,
SkeletonConfigOffsets.LOWER_ARM,
SkeletonConfigOffsets.UPPER_LEG,
SkeletonConfigOffsets.LOWER_LEG,
SkeletonConfigOffsets.FOOT_LENGTH,
)
}
}

View File

@@ -1,408 +0,0 @@
package dev.slimevr.autobone
import dev.slimevr.VRServer
import dev.slimevr.autobone.AutoBone.AutoBoneResults
import dev.slimevr.autobone.AutoBone.Companion.loadDir
import dev.slimevr.autobone.errors.AutoBoneException
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.poseframeformat.PoseRecorder
import dev.slimevr.poseframeformat.PoseRecorder.RecordingProgress
import dev.slimevr.poseframeformat.trackerdata.TrackerFrameData
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import io.eiren.util.StringUtils
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import org.apache.commons.lang3.tuple.Pair
import java.util.*
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
class AutoBoneHandler(private val server: VRServer) {
private val poseRecorder: PoseRecorder = PoseRecorder(server)
private val autoBone: AutoBone = AutoBone(server)
private val recordingLock = ReentrantLock()
private var recordingThread: Thread? = null
private val saveRecordingLock = ReentrantLock()
private var saveRecordingThread: Thread? = null
private val autoBoneLock = ReentrantLock()
private var autoBoneThread: Thread? = null
private val listeners = CopyOnWriteArrayList<AutoBoneListener>()
fun addListener(listener: AutoBoneListener) {
listeners.add(listener)
}
fun removeListener(listener: AutoBoneListener) {
listeners.removeIf { listener == it }
}
private fun announceProcessStatus(
processType: AutoBoneProcessType,
message: String? = null,
current: Long = -1L,
total: Long = -1L,
eta: Float = -1f,
completed: Boolean = false,
success: Boolean = true,
) {
listeners.forEach {
it.onAutoBoneProcessStatus(
processType,
message,
current,
total,
eta,
completed,
success,
)
}
}
@Throws(AutoBoneException::class)
private fun processFrames(frames: PoseFrames): AutoBoneResults = autoBone
.processFrames(frames) { epoch ->
listeners.forEach { listener -> listener.onAutoBoneEpoch(epoch) }
}
fun startProcessByType(processType: AutoBoneProcessType?): Boolean {
when (processType) {
AutoBoneProcessType.RECORD -> startRecording()
AutoBoneProcessType.SAVE -> saveRecording()
AutoBoneProcessType.PROCESS -> processRecording()
else -> {
return false
}
}
return true
}
fun startRecording() {
recordingLock.withLock {
// Prevent running multiple times
if (recordingThread != null) {
return
}
recordingThread = thread(start = true) { startRecordingThread() }
}
}
private fun startRecordingThread() {
try {
if (poseRecorder.isReadyToRecord) {
announceProcessStatus(AutoBoneProcessType.RECORD, "Recording...")
// ex. 1000 samples at 20 ms per sample is 20 seconds
val sampleCount = autoBone.globalConfig.sampleCount
val sampleRate = autoBone.globalConfig.sampleRateMs / 1000f
// Calculate total time in seconds
val totalTime: Float = sampleCount * sampleRate
val framesFuture = poseRecorder
.startFrameRecording(
sampleCount,
sampleRate,
) { progress: RecordingProgress ->
announceProcessStatus(
AutoBoneProcessType.RECORD,
current = progress.frame.toLong(),
total = progress.totalFrames.toLong(),
eta = totalTime - (progress.frame * totalTime / progress.totalFrames),
)
}
val frames = framesFuture.get()
LogManager.info("[AutoBone] Done recording!")
// Save a recurring recording for users to send as debug info
announceProcessStatus(AutoBoneProcessType.RECORD, "Saving recording...")
autoBone.saveRecording(frames, "LastABRecording.pfs")
if (autoBone.globalConfig.saveRecordings) {
announceProcessStatus(
AutoBoneProcessType.RECORD,
"Saving recording (from config option)...",
)
autoBone.saveRecording(frames)
}
listeners.forEach { listener: AutoBoneListener -> listener.onAutoBoneRecordingEnd(frames) }
announceProcessStatus(
AutoBoneProcessType.RECORD,
"Done recording!",
completed = true,
success = true,
)
} else {
announceProcessStatus(
AutoBoneProcessType.RECORD,
"The server is not ready to record",
completed = true,
success = false,
)
LogManager.severe("[AutoBone] Unable to record...")
return
}
} catch (e: Exception) {
announceProcessStatus(
AutoBoneProcessType.RECORD,
"Recording failed: ${e.message}",
completed = true,
success = false,
)
LogManager.severe("[AutoBone] Failed recording!", e)
} finally {
recordingThread = null
}
}
fun stopRecording() {
if (poseRecorder.isRecording) {
poseRecorder.stopFrameRecording()
}
}
fun cancelRecording() {
if (poseRecorder.isRecording) {
poseRecorder.cancelFrameRecording()
}
}
fun saveRecording() {
saveRecordingLock.withLock {
// Prevent running multiple times
if (saveRecordingThread != null) {
return
}
saveRecordingThread = thread(start = true) { saveRecordingThread() }
}
}
private fun saveRecordingThread() {
try {
val framesFuture = poseRecorder.framesAsync
if (framesFuture != null) {
announceProcessStatus(AutoBoneProcessType.SAVE, "Waiting for recording...")
val frames = framesFuture.get()
check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
check(frames.maxFrameCount > 0) { "Recording has no frames." }
announceProcessStatus(AutoBoneProcessType.SAVE, "Saving recording...")
autoBone.saveRecording(frames)
announceProcessStatus(
AutoBoneProcessType.SAVE,
"Recording saved!",
completed = true,
success = true,
)
} else {
announceProcessStatus(
AutoBoneProcessType.SAVE,
"No recording found",
completed = true,
success = false,
)
LogManager.severe("[AutoBone] Unable to save, no recording was done...")
return
}
} catch (e: Exception) {
announceProcessStatus(
AutoBoneProcessType.SAVE,
"Failed to save recording: ${e.message}",
completed = true,
success = false,
)
LogManager.severe("[AutoBone] Failed to save recording!", e)
} finally {
saveRecordingThread = null
}
}
fun processRecording() {
autoBoneLock.withLock {
// Prevent running multiple times
if (autoBoneThread != null) {
return
}
autoBoneThread = thread(start = true) { processRecordingThread() }
}
}
private fun processRecordingThread() {
try {
announceProcessStatus(AutoBoneProcessType.PROCESS, "Loading recordings...")
val frameRecordings = autoBone.loadRecordings()
if (!frameRecordings.isEmpty()) {
LogManager.info("[AutoBone] Done loading frames!")
} else {
val framesFuture = poseRecorder.framesAsync
if (framesFuture != null) {
announceProcessStatus(AutoBoneProcessType.PROCESS, "Waiting for recording...")
val frames = framesFuture.get()
frameRecordings.add(Pair.of("<Recording>", frames))
} else {
announceProcessStatus(
AutoBoneProcessType.PROCESS,
"No recordings found...",
completed = true,
success = false,
)
LogManager
.severe(
"[AutoBone] No recordings found in \"${loadDir.path}\" and no recording was done...",
)
return
}
}
announceProcessStatus(AutoBoneProcessType.PROCESS, "Processing recording(s)...")
LogManager.info("[AutoBone] Processing frames...")
val errorStats = StatsCalculator()
val offsetStats = EnumMap<SkeletonConfigOffsets, StatsCalculator>(
SkeletonConfigOffsets::class.java,
)
val skeletonConfigManagerBuffer = SkeletonConfigManager(false)
for ((key, value) in frameRecordings) {
LogManager.info("[AutoBone] Processing frames from \"$key\"...")
// Output tracker info for the recording
printTrackerInfo(value.frameHolders)
// Actually process the recording
val autoBoneResults = processFrames(value)
LogManager.info("[AutoBone] Done processing!")
// #region Stats/Values
// Accumulate height error
errorStats.addValue(autoBoneResults.heightDifference)
// Accumulate length values
for (offset in autoBoneResults.configValues) {
val statCalc = offsetStats.getOrPut(offset.key) {
StatsCalculator()
}
// Multiply by 100 to get cm
statCalc.addValue(offset.value * 100f)
}
// Calculate and output skeleton ratios
skeletonConfigManagerBuffer.setOffsets(autoBoneResults.configValues)
printSkeletonRatios(skeletonConfigManagerBuffer)
LogManager.info("[AutoBone] Length values: ${autoBone.lengthsString}")
}
// Length value stats
val averageLengthVals = StringBuilder()
offsetStats.forEach { (key, value) ->
if (averageLengthVals.isNotEmpty()) {
averageLengthVals.append(", ")
}
averageLengthVals
.append(key.configKey)
.append(": ")
.append(StringUtils.prettyNumber(value.mean, 2))
.append(" (SD ")
.append(StringUtils.prettyNumber(value.standardDeviation, 2))
.append(")")
}
LogManager.info("[AutoBone] Average length values: $averageLengthVals")
// Height error stats
LogManager
.info(
"[AutoBone] Average height error: ${
StringUtils.prettyNumber(errorStats.mean, 6)
} (SD ${StringUtils.prettyNumber(errorStats.standardDeviation, 6)})",
)
// #endregion
listeners.forEach { listener: AutoBoneListener -> listener.onAutoBoneEnd(autoBone.offsets) }
announceProcessStatus(
AutoBoneProcessType.PROCESS,
"Done processing!",
completed = true,
success = true,
)
} catch (e: Exception) {
announceProcessStatus(
AutoBoneProcessType.PROCESS,
"Processing failed: ${e.message}",
completed = true,
success = false,
)
LogManager.severe("[AutoBone] Failed adjustment!", e)
} finally {
autoBoneThread = null
}
}
private fun printTrackerInfo(trackers: FastList<TrackerFrames>) {
val trackerInfo = StringBuilder()
for (tracker in trackers) {
val frame = tracker?.tryGetFrame(0) ?: continue
// Add a comma if this is not the first item listed
if (trackerInfo.isNotEmpty()) {
trackerInfo.append(", ")
}
trackerInfo.append(frame.tryGetTrackerPosition()?.designation ?: "unassigned")
// Represent the data flags
val trackerFlags = StringBuilder()
if (frame.hasData(TrackerFrameData.ROTATION)) {
trackerFlags.append("R")
}
if (frame.hasData(TrackerFrameData.POSITION)) {
trackerFlags.append("P")
}
if (frame.hasData(TrackerFrameData.ACCELERATION)) {
trackerFlags.append("A")
}
if (frame.hasData(TrackerFrameData.RAW_ROTATION)) {
trackerFlags.append("r")
}
// If there are data flags, print them in brackets after the designation
if (trackerFlags.isNotEmpty()) {
trackerInfo.append(" (").append(trackerFlags).append(")")
}
}
LogManager.info("[AutoBone] (${trackers.size} trackers) [$trackerInfo]")
}
private fun printSkeletonRatios(skeleton: SkeletonConfigManager) {
val neckLength = skeleton.getOffset(SkeletonConfigOffsets.NECK)
val upperChestLength = skeleton.getOffset(SkeletonConfigOffsets.UPPER_CHEST)
val chestLength = skeleton.getOffset(SkeletonConfigOffsets.CHEST)
val waistLength = skeleton.getOffset(SkeletonConfigOffsets.WAIST)
val hipLength = skeleton.getOffset(SkeletonConfigOffsets.HIP)
val torsoLength = upperChestLength + chestLength + waistLength + hipLength
val hipWidth = skeleton.getOffset(SkeletonConfigOffsets.HIPS_WIDTH)
val legLength = skeleton.getOffset(SkeletonConfigOffsets.UPPER_LEG) +
skeleton.getOffset(SkeletonConfigOffsets.LOWER_LEG)
val lowerLegLength = skeleton.getOffset(SkeletonConfigOffsets.LOWER_LEG)
val neckTorso = neckLength / torsoLength
val chestTorso = (upperChestLength + chestLength) / torsoLength
val torsoWaist = hipWidth / torsoLength
val legTorso = legLength / torsoLength
val legBody = legLength / (torsoLength + neckLength)
val kneeLeg = lowerLegLength / legLength
LogManager.info(
"[AutoBone] Ratios: [{Neck-Torso: ${
StringUtils.prettyNumber(neckTorso)}}, {Chest-Torso: ${
StringUtils.prettyNumber(chestTorso)}}, {Torso-Waist: ${
StringUtils.prettyNumber(torsoWaist)}}, {Leg-Torso: ${
StringUtils.prettyNumber(legTorso)}}, {Leg-Body: ${
StringUtils.prettyNumber(legBody)}}, {Knee-Leg: ${
StringUtils.prettyNumber(kneeLeg)}}]",
)
}
fun applyValues() {
autoBone.applyAndSaveConfig()
}
}

View File

@@ -1,22 +0,0 @@
package dev.slimevr.autobone
import dev.slimevr.autobone.AutoBone.Epoch
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import java.util.*
interface AutoBoneListener {
fun onAutoBoneProcessStatus(
processType: AutoBoneProcessType,
message: String?,
current: Long,
total: Long,
eta: Float,
completed: Boolean,
success: Boolean,
)
fun onAutoBoneRecordingEnd(recording: PoseFrames)
fun onAutoBoneEpoch(epoch: Epoch)
fun onAutoBoneEnd(configValues: EnumMap<SkeletonConfigOffsets, Float>)
}

View File

@@ -1,15 +0,0 @@
package dev.slimevr.autobone
enum class AutoBoneProcessType(val id: Int) {
NONE(0),
RECORD(1),
SAVE(2),
PROCESS(3),
;
companion object {
fun getById(id: Int): AutoBoneProcessType? = byId[id]
}
}
private val byId = AutoBoneProcessType.values().associateBy { it.id }

View File

@@ -1,13 +0,0 @@
package dev.slimevr.autobone
class AutoBoneStep(
var hmdHeight: Float = 1f,
val targetHmdHeight: Float = 1f,
var adjustRate: Float = 0f,
) {
val errorStats = StatsCalculator()
val heightOffset: Float
get() = targetHmdHeight - hmdHeight
}

View File

@@ -1,84 +0,0 @@
package dev.slimevr.autobone
import dev.slimevr.autobone.AutoBone.Companion.MIN_SLIDE_DIST
import dev.slimevr.autobone.AutoBone.Companion.SYMM_CONFIGS
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import io.github.axisangles.ktmath.Vector3
object BoneContribution {
/**
* Computes the local tail position of the bone after rotation.
*/
fun getBoneLocalTail(
skeleton: HumanPoseManager,
boneType: BoneType,
): Vector3 {
val bone = skeleton.getBone(boneType)
return bone.getTailPosition() - bone.getPosition()
}
/**
* Computes the direction of the bone tail's movement between skeletons 1 and 2.
*/
fun getBoneLocalTailDir(
skeleton1: HumanPoseManager,
skeleton2: HumanPoseManager,
boneType: BoneType,
): Vector3? {
val boneOff = getBoneLocalTail(skeleton2, boneType) - getBoneLocalTail(skeleton1, boneType)
val boneOffLen = boneOff.len()
// If the offset is approx 0, just return null so it can be easily ignored
return if (boneOffLen > MIN_SLIDE_DIST) boneOff / boneOffLen else null
}
/**
* Predicts how much the provided config should be affecting the slide offsets
* of the left and right ankles.
*/
fun getSlideDot(
skeleton1: HumanPoseManager,
skeleton2: HumanPoseManager,
config: SkeletonConfigOffsets,
slideL: Vector3?,
slideR: Vector3?,
): Float {
var slideDot = 0f
// Used for right offset if not a symmetric bone
var boneOffL: Vector3? = null
// Treat null as 0
if (slideL != null) {
boneOffL = getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
// Treat null as 0
if (boneOffL != null) {
slideDot += slideL.dot(boneOffL)
}
}
// Treat null as 0
if (slideR != null) {
// IMPORTANT: This assumption for acquiring BoneType only works if
// SkeletonConfigOffsets is set up to only affect one BoneType, make sure no
// changes to SkeletonConfigOffsets goes against this assumption, please!
val boneOffR = if (SYMM_CONFIGS.contains(config)) {
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[1])
} else if (slideL != null) {
// Use cached offset if slideL was used
boneOffL
} else {
// Compute offset if missing because of slideL
getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0])
}
// Treat null as 0
if (boneOffR != null) {
slideDot += slideR.dot(boneOffR)
}
}
return slideDot / 2f
}
}

View File

@@ -1,90 +0,0 @@
package dev.slimevr.autobone
import kotlin.random.Random
object PoseFrameIterator {
fun <T> iterateFrames(
step: PoseFrameStep<T>,
) {
check(step.frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
check(step.maxFrameCount > 0) { "Recording has no frames." }
// Epoch loop, each epoch is one full iteration over the full dataset
for (epoch in (if (step.config.calcInitError) -1 else 0) until step.config.numEpochs) {
// Set the current epoch to process
step.epoch = epoch
// Process the epoch
epoch(step)
}
}
private fun randomIndices(count: Int, random: Random): IntArray {
val randIndices = IntArray(count)
var zeroPos = -1
for (i in 0 until count) {
var index = random.nextInt(count)
if (i > 0) {
while (index == zeroPos || randIndices[index] > 0) {
index = random.nextInt(count)
}
} else {
zeroPos = index
}
randIndices[index] = i
}
return randIndices
}
private fun <T> epoch(step: PoseFrameStep<T>) {
val config = step.config
val frameCount = step.maxFrameCount
// Perform any setup that needs to be done before the current epoch
step.preEpoch?.accept(step)
val randIndices = if (config.randomizeFrameOrder) {
randomIndices(step.maxFrameCount, step.random)
} else {
null
}
// Iterate over the frames using a cursor and an offset for comparing
// frames a certain number of frames apart
var cursorOffset = config.minDataDistance
while (cursorOffset <= config.maxDataDistance &&
cursorOffset < frameCount
) {
var frameCursor = 0
while (frameCursor < frameCount - cursorOffset) {
val frameCursor2 = frameCursor + cursorOffset
// Then set the frame cursors and apply them to both skeletons
if (config.randomizeFrameOrder && randIndices != null) {
step
.setCursors(
randIndices[frameCursor],
randIndices[frameCursor2],
updatePlayerCursors = true,
)
} else {
step.setCursors(
frameCursor,
frameCursor2,
updatePlayerCursors = true,
)
}
// Process the iteration
step.onStep.accept(step)
// Move on to the next iteration
frameCursor += config.cursorIncrement
}
cursorOffset++
}
step.postEpoch?.accept(step)
}
}

View File

@@ -1,70 +0,0 @@
package dev.slimevr.autobone
import dev.slimevr.config.AutoBoneConfig
import dev.slimevr.config.ConfigManager
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.poseframeformat.player.TrackerFramesPlayer
import dev.slimevr.tracking.processor.HumanPoseManager
import java.util.function.Consumer
import kotlin.random.Random
class PoseFrameStep<T>(
val config: AutoBoneConfig,
/** The config to initialize skeletons. */
serverConfig: ConfigManager? = null,
val frames: PoseFrames,
/** The consumer run before each epoch. */
val preEpoch: Consumer<PoseFrameStep<T>>? = null,
/** The consumer run for each step. */
val onStep: Consumer<PoseFrameStep<T>>,
/** The consumer run after each epoch. */
val postEpoch: Consumer<PoseFrameStep<T>>? = null,
/** The current epoch. */
var epoch: Int = 0,
/** The current frame cursor position in [frames] for skeleton1. */
var cursor1: Int = 0,
/** The current frame cursor position in [frames] for skeleton2. */
var cursor2: Int = 0,
randomSeed: Long = 0,
val data: T,
) {
var maxFrameCount = frames.maxFrameCount
val framePlayer1 = TrackerFramesPlayer(frames)
val framePlayer2 = TrackerFramesPlayer(frames)
val trackers1 = framePlayer1.trackers.toList()
val trackers2 = framePlayer2.trackers.toList()
val skeleton1 = HumanPoseManager(trackers1)
val skeleton2 = HumanPoseManager(trackers2)
val random = Random(randomSeed)
init {
// Load server configs into the skeleton
if (serverConfig != null) {
skeleton1.loadFromConfig(serverConfig)
skeleton2.loadFromConfig(serverConfig)
}
// Disable leg tweaks and IK solver, these will mess with the resulting positions
skeleton1.setLegTweaksEnabled(false)
skeleton2.setLegTweaksEnabled(false)
}
fun setCursors(cursor1: Int, cursor2: Int, updatePlayerCursors: Boolean) {
this.cursor1 = cursor1
this.cursor2 = cursor2
if (updatePlayerCursors) {
updatePlayerCursors()
}
}
fun updatePlayerCursors() {
framePlayer1.setCursors(cursor1)
framePlayer2.setCursors(cursor2)
skeleton1.update()
skeleton2.update()
}
}

View File

@@ -1,43 +0,0 @@
package dev.slimevr.autobone
import kotlin.math.*
/**
* This is a stat calculator based on Welford's online algorithm
* https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford%27s_online_algorithm
*/
class StatsCalculator {
private var count = 0
var mean = 0f
private set
private var m2 = 0f
fun reset() {
count = 0
mean = 0f
m2 = 0f
}
fun addValue(newValue: Float) {
count += 1
val delta = newValue - mean
mean += delta / count
val delta2 = newValue - mean
m2 += delta * delta2
}
val variance: Float
get() = if (count < 1) {
Float.NaN
} else {
m2 / count
}
val sampleVariance: Float
get() = if (count < 2) {
Float.NaN
} else {
m2 / (count - 1)
}
val standardDeviation: Float
get() = sqrt(variance)
}

View File

@@ -1,14 +0,0 @@
package dev.slimevr.autobone.errors
class AutoBoneException : Exception {
constructor()
constructor(message: String?) : super(message)
constructor(cause: Throwable?) : super(cause)
constructor(message: String?, cause: Throwable?) : super(message, cause)
constructor(
message: String?,
cause: Throwable?,
enableSuppression: Boolean,
writableStackTrace: Boolean,
) : super(message, cause, enableSuppression, writableStackTrace)
}

View File

@@ -1,123 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import dev.slimevr.autobone.errors.proportions.ProportionLimiter
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.config.SkeletonConfigManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import kotlin.math.*
// The distance from average human proportions
class BodyProportionError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getBodyProportionError(
step.skeleton1,
// Skeletons are now normalized to reduce bias, so height is always 1
1f,
)
fun getBodyProportionError(humanPoseManager: HumanPoseManager, fullHeight: Float): Float {
var sum = 0f
for (limiter in proportionLimits) {
sum += abs(limiter.getProportionError(humanPoseManager, fullHeight))
}
return sum
}
companion object {
// The headset height is not the full height! This value compensates for the
// offset from the headset height to the user full height
// From Drillis and Contini (1966)
@JvmField
var eyeHeightToHeightRatio = 0.936f
val defaultHeight = SkeletonConfigManager.HEIGHT_OFFSETS.sumOf {
it.defaultValue.toDouble()
}.toFloat()
private fun makeLimiter(
offset: SkeletonConfigOffsets,
range: Float,
scaleByHeight: Boolean = true,
) = ProportionLimiter(
if (scaleByHeight) {
offset.defaultValue / defaultHeight
} else {
offset.defaultValue
},
offset,
range,
scaleByHeight,
)
// "Expected" are values from Drillis and Contini (1966)
// Default are values from experimentation by the SlimeVR community
/**
* Proportions are based off the headset height (or eye height), not the total height of the user.
* To use the total height of the user, multiply it by [eyeHeightToHeightRatio] and use that in the limiters.
*/
val proportionLimits = arrayOf<ProportionLimiter>(
makeLimiter(
SkeletonConfigOffsets.HEAD,
0.01f,
scaleByHeight = false,
),
// Expected: 0.052
makeLimiter(
SkeletonConfigOffsets.NECK,
0.002f,
),
makeLimiter(
SkeletonConfigOffsets.SHOULDERS_WIDTH,
0.04f,
scaleByHeight = false,
),
makeLimiter(
SkeletonConfigOffsets.UPPER_ARM,
0.02f,
),
makeLimiter(
SkeletonConfigOffsets.LOWER_ARM,
0.02f,
),
makeLimiter(
SkeletonConfigOffsets.UPPER_CHEST,
0.01f,
),
makeLimiter(
SkeletonConfigOffsets.CHEST,
0.01f,
),
makeLimiter(
SkeletonConfigOffsets.WAIST,
0.05f,
),
makeLimiter(
SkeletonConfigOffsets.HIP,
0.01f,
),
// Expected: 0.191
makeLimiter(
SkeletonConfigOffsets.HIPS_WIDTH,
0.04f,
scaleByHeight = false,
),
// Expected: 0.245
makeLimiter(
SkeletonConfigOffsets.UPPER_LEG,
0.02f,
),
// Expected: 0.246 (0.285 including below ankle, could use a separate
// offset?)
makeLimiter(
SkeletonConfigOffsets.LOWER_LEG,
0.02f,
),
)
@JvmStatic
val proportionLimitMap = proportionLimits.associateBy { it.skeletonConfigOffset }
}
}

View File

@@ -1,50 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import io.github.axisangles.ktmath.Vector3
import kotlin.math.*
// The offset between the height both feet at one instant and over time
class FootHeightOffsetError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getSlideError(
step.skeleton1.skeleton,
step.skeleton2.skeleton,
)
companion object {
fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float = getFootHeightError(
skeleton1.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
skeleton1.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
skeleton2.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
skeleton2.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
)
fun getFootHeightError(
leftFoot1: Vector3,
rightFoot1: Vector3,
leftFoot2: Vector3,
rightFoot2: Vector3,
): Float {
val lFoot1Y = leftFoot1.y
val rFoot1Y = rightFoot1.y
val lFoot2Y = leftFoot2.y
val rFoot2Y = rightFoot2.y
// Compute all combinations of heights
val dist1 = abs(lFoot1Y - rFoot1Y)
val dist2 = abs(lFoot1Y - lFoot2Y)
val dist3 = abs(lFoot1Y - rFoot2Y)
val dist4 = abs(rFoot1Y - lFoot2Y)
val dist5 = abs(rFoot1Y - rFoot2Y)
val dist6 = abs(lFoot2Y - rFoot2Y)
// Divide by 12 (6 values * 2 to halve) to halve and average, it's
// halved because you want to approach a midpoint, not the other point
return (dist1 + dist2 + dist3 + dist4 + dist5 + dist6) / 12f
}
}
}

View File

@@ -1,16 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import kotlin.math.*
// The difference from the current height to the target height
class HeightError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getHeightError(
step.data.hmdHeight,
step.data.targetHmdHeight,
)
fun getHeightError(currentHeight: Float, targetHeight: Float): Float = abs(targetHeight - currentHeight)
}

View File

@@ -1,9 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
interface IAutoBoneError {
@Throws(AutoBoneException::class)
fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float
}

View File

@@ -1,50 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import io.github.axisangles.ktmath.Vector3
import kotlin.math.*
// The change in distance between both of the ankles over time
class OffsetSlideError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getSlideError(
step.skeleton1.skeleton,
step.skeleton2.skeleton,
)
companion object {
fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float = getSlideError(
skeleton1.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
skeleton1.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
skeleton2.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(),
skeleton2.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(),
)
fun getSlideError(
leftFoot1: Vector3,
rightFoot1: Vector3,
leftFoot2: Vector3,
rightFoot2: Vector3,
): Float {
val slideDist1 = (rightFoot1 - leftFoot1).len()
val slideDist2 = (rightFoot2 - leftFoot2).len()
val slideDist3 = (rightFoot2 - leftFoot1).len()
val slideDist4 = (rightFoot1 - leftFoot2).len()
// Compute all combinations of distances
val dist1 = abs(slideDist1 - slideDist2)
val dist2 = abs(slideDist1 - slideDist3)
val dist3 = abs(slideDist1 - slideDist4)
val dist4 = abs(slideDist2 - slideDist3)
val dist5 = abs(slideDist2 - slideDist4)
val dist6 = abs(slideDist3 - slideDist4)
// Divide by 12 (6 values * 2 to halve) to halve and average, it's
// halved because you want to approach a midpoint, not the other point
return (dist1 + dist2 + dist3 + dist4 + dist5 + dist6) / 12f
}
}
}

View File

@@ -1,55 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
// The distance of any points to the corresponding absolute position
class PositionError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float {
val trackers = step.frames.frameHolders
return (
(
getPositionError(
trackers,
step.cursor1,
step.skeleton1.skeleton,
) +
getPositionError(
trackers,
step.cursor2,
step.skeleton2.skeleton,
)
) /
2f
)
}
companion object {
fun getPositionError(
trackers: List<TrackerFrames>,
cursor: Int,
skeleton: HumanSkeleton,
): Float {
var offset = 0f
var offsetCount = 0
for (tracker in trackers) {
val trackerFrame = tracker.tryGetFrame(cursor) ?: continue
val position = trackerFrame.tryGetPosition() ?: continue
val trackerRole = trackerFrame.tryGetTrackerPosition()?.trackerRole ?: continue
try {
val computedTracker = skeleton.getComputedTracker(trackerRole)
offset += (position - computedTracker.position).len()
offsetCount++
} catch (_: Exception) {
// Ignore unsupported positions
}
}
return if (offsetCount > 0) offset / offsetCount else 0f
}
}
}

View File

@@ -1,55 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import kotlin.math.*
// The difference between offset of absolute position and the corresponding point over time
class PositionOffsetError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float {
val trackers = step.frames.frameHolders
return getPositionOffsetError(
trackers,
step.cursor1,
step.cursor2,
step.skeleton1.skeleton,
step.skeleton2.skeleton,
)
}
fun getPositionOffsetError(
trackers: List<TrackerFrames>,
cursor1: Int,
cursor2: Int,
skeleton1: HumanSkeleton,
skeleton2: HumanSkeleton,
): Float {
var offset = 0f
var offsetCount = 0
for (tracker in trackers) {
val trackerFrame1 = tracker.tryGetFrame(cursor1) ?: continue
val position1 = trackerFrame1.tryGetPosition() ?: continue
val trackerRole1 = trackerFrame1.tryGetTrackerPosition()?.trackerRole ?: continue
val trackerFrame2 = tracker.tryGetFrame(cursor2) ?: continue
val position2 = trackerFrame2.tryGetPosition() ?: continue
val trackerRole2 = trackerFrame2.tryGetTrackerPosition()?.trackerRole ?: continue
try {
val computedTracker1 = skeleton1.getComputedTracker(trackerRole1)
val computedTracker2 = skeleton2.getComputedTracker(trackerRole2)
val dist1 = (position1 - computedTracker1.position).len()
val dist2 = (position2 - computedTracker2.position).len()
offset += abs(dist2 - dist1)
offsetCount++
} catch (_: Exception) {
// Ignore unsupported positions
}
}
return if (offsetCount > 0) offset / offsetCount else 0f
}
}

View File

@@ -1,44 +0,0 @@
package dev.slimevr.autobone.errors
import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.PoseFrameStep
import dev.slimevr.tracking.processor.Bone
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
// The change in position of the ankle over time
class SlideError : IAutoBoneError {
@Throws(AutoBoneException::class)
override fun getStepError(step: PoseFrameStep<AutoBoneStep>): Float = getSlideError(
step.skeleton1.skeleton,
step.skeleton2.skeleton,
)
companion object {
fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float {
// Calculate and average between both feet
return (
getSlideError(skeleton1, skeleton2, BoneType.LEFT_LOWER_LEG) +
getSlideError(skeleton1, skeleton2, BoneType.RIGHT_LOWER_LEG)
) /
2f
}
fun getSlideError(
skeleton1: HumanSkeleton,
skeleton2: HumanSkeleton,
bone: BoneType,
): Float {
// Calculate and average between both feet
return getSlideError(
skeleton1.getBone(bone),
skeleton2.getBone(bone),
)
}
fun getSlideError(bone1: Bone, bone2: Bone): Float {
// Return the midpoint distance
return (bone2.getTailPosition() - bone1.getTailPosition()).len() / 2f
}
}
}

View File

@@ -1,80 +0,0 @@
package dev.slimevr.autobone.errors.proportions
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import kotlin.math.*
class ProportionLimiter {
val targetRatio: Float
val skeletonConfigOffset: SkeletonConfigOffsets
val scaleByHeight: Boolean
val positiveRange: Float
val negativeRange: Float
/**
* @param targetRatio The bone to height ratio to target
* @param skeletonConfigOffset The SkeletonConfigOffset to use for the length
* @param range The range from the target ratio to accept (ex. 0.1)
* @param scaleByHeight True if the bone length will be scaled by the height
*/
constructor(
targetRatio: Float,
skeletonConfigOffset: SkeletonConfigOffsets,
range: Float,
scaleByHeight: Boolean = true,
) {
this.targetRatio = targetRatio
this.skeletonConfigOffset = skeletonConfigOffset
this.scaleByHeight = scaleByHeight
// Handle if someone puts in a negative value
val absRange = abs(range)
positiveRange = absRange
negativeRange = -absRange
}
/**
* @param targetRatio The bone to height ratio to target
* @param skeletonConfigOffset The SkeletonConfigOffset to use for the length
* @param positiveRange The positive range from the target ratio to accept
* (ex. 0.1)
* @param negativeRange The negative range from the target ratio to accept
* (ex. -0.1)
* @param scaleByHeight True if the bone length will be scaled by the height
*/
constructor(
targetRatio: Float,
skeletonConfigOffset: SkeletonConfigOffsets,
positiveRange: Float,
negativeRange: Float,
scaleByHeight: Boolean = true,
) {
// If the positive range is less than the negative range, something is wrong
require(positiveRange >= negativeRange) { "positiveRange must not be less than negativeRange" }
this.targetRatio = targetRatio
this.skeletonConfigOffset = skeletonConfigOffset
this.scaleByHeight = scaleByHeight
this.positiveRange = positiveRange
this.negativeRange = negativeRange
}
fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float {
val boneLength = humanPoseManager.getOffset(skeletonConfigOffset)
val ratioOffset = if (scaleByHeight) {
targetRatio - boneLength / height
} else {
targetRatio - boneLength
}
// If the range is exceeded, return the offset from the range limit
if (ratioOffset > positiveRange) {
return ratioOffset - positiveRange
} else if (ratioOffset < negativeRange) {
return ratioOffset - negativeRange
}
return 0f
}
}

View File

@@ -1,58 +0,0 @@
package dev.slimevr.bridge
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerRole
import dev.slimevr.util.ann.VRServerThread
/**
* Bridge handles sending and receiving tracker data between SlimeVR and other
* systems like VR APIs (SteamVR, OpenXR, etc), apps and protocols (VMC,
* WebSocket, TIP). It can create and manage tracker received from the **remote
* side** or send shared **local trackers** to the other side.
*/
interface Bridge {
@VRServerThread
fun dataRead()
@VRServerThread
fun dataWrite()
/**
* Adds shared tracker to the bridge. Bridge should notify the other side of
* this tracker, if it's the type of tracker this bridge serves, and start
* sending data each update
*
* @param tracker
*/
@VRServerThread
fun addSharedTracker(tracker: Tracker?)
/**
* Removes tracker from a bridge. If the other side supports tracker
* removal, bridge should notify it and stop sending new data. If it doesn't
* support tracker removal, the bridge can either stop sending new data, or
* keep sending it if it's available.
*
* @param tracker
*/
@VRServerThread
fun removeSharedTracker(tracker: Tracker?)
@VRServerThread
fun startBridge()
fun isConnected(): Boolean
}
interface ISteamVRBridge : Bridge {
fun getShareSetting(role: TrackerRole): Boolean
fun changeShareSettings(role: TrackerRole?, share: Boolean)
fun updateShareSettingsAutomatically(): Boolean
fun getAutomaticSharedTrackers(): Boolean
fun setAutomaticSharedTrackers(value: Boolean)
fun getBridgeConfigKey(): String
}

View File

@@ -1,9 +0,0 @@
package dev.slimevr.bridge;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(value = RetentionPolicy.SOURCE)
public @interface BridgeThread {
}

View File

@@ -1,28 +0,0 @@
package dev.slimevr.config
class AutoBoneConfig {
var cursorIncrement = 2
var minDataDistance = 1
var maxDataDistance = 1
var numEpochs = 50
var printEveryNumEpochs = 25
var initialAdjustRate = 10.0f
var adjustRateDecay = 1.0f
var slideErrorFactor = 1.0f
var offsetSlideErrorFactor = 0.0f
var footHeightOffsetErrorFactor = 0.0f
var bodyProportionErrorFactor = 0.05f
var heightErrorFactor = 0.0f
var positionErrorFactor = 0.0f
var positionOffsetErrorFactor = 0.0f
var calcInitError = false
var randomizeFrameOrder = true
var scaleEachStep = true
var sampleCount = 1500
var sampleRateMs = 20L
var saveRecordings = false
var useSkeletonHeight = false
var randSeed = 4L
var useFrameFiltering = false
var maxFinalError = 0.03f
}

View File

@@ -1,33 +0,0 @@
package dev.slimevr.config;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers;
import dev.slimevr.config.serializers.BooleanMapDeserializer;
import dev.slimevr.tracking.trackers.TrackerRole;
import java.util.HashMap;
import java.util.Map;
public class BridgeConfig {
@JsonDeserialize(using = BooleanMapDeserializer.class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
public Map<String, Boolean> trackers = new HashMap<>();
public boolean automaticSharedTrackersToggling = true;
public BridgeConfig() {
}
public boolean getBridgeTrackerRole(TrackerRole role, boolean def) {
return trackers.getOrDefault(role.name().toLowerCase(), def);
}
public void setBridgeTrackerRole(TrackerRole role, boolean val) {
this.trackers.put(role.name().toLowerCase(), val);
}
public Map<String, Boolean> getTrackers() {
return trackers;
}
}

View File

@@ -1,176 +0,0 @@
package dev.slimevr.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
import com.github.jonpeterson.jackson.module.versioning.VersioningModule;
import dev.slimevr.config.serializers.QuaternionDeserializer;
import dev.slimevr.config.serializers.QuaternionSerializer;
import io.eiren.util.ann.ThreadSafe;
import io.eiren.util.logging.LogManager;
import io.github.axisangles.ktmath.ObjectQuaternion;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.*;
import java.util.Comparator;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class ConfigManager {
private final String configPath;
private final ObjectMapper om;
private VRConfig vrConfig;
public ConfigManager(String configPath) {
this.configPath = configPath;
om = new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES));
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
om.registerModule(new VersioningModule());
SimpleModule quaternionModule = new SimpleModule();
quaternionModule.addSerializer(ObjectQuaternion.class, new QuaternionSerializer());
quaternionModule.addDeserializer(ObjectQuaternion.class, new QuaternionDeserializer());
om.registerModule(quaternionModule);
}
public void loadConfig() {
try {
this.vrConfig = om
.readValue(new FileInputStream(configPath), VRConfig.class);
} catch (FileNotFoundException e) {
// Config file didn't exist, is not an error
} catch (IOException e) {
// Log the exception
LogManager.severe("Config failed to load: " + e);
// Make a backup of the erroneous config
backupConfig();
}
if (this.vrConfig == null) {
this.vrConfig = new VRConfig();
}
}
static public void atomicMove(Path from, Path to) throws IOException {
try {
// Atomic move to overwrite
Files.move(from, to, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException | FileAlreadyExistsException e) {
// Atomic move not supported or does not replace, try just replacing
Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
}
}
public void backupConfig() {
Path cfgFile = Paths.get(configPath);
Path tmpBakCfgFile = Paths.get(configPath + ".bak.tmp");
Path bakCfgFile = Paths.get(configPath + ".bak");
try {
Files
.copy(
cfgFile,
tmpBakCfgFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.COPY_ATTRIBUTES
);
LogManager.info("Made a backup copy of config to \"" + tmpBakCfgFile + "\"");
} catch (IOException e) {
LogManager
.severe(
"Unable to make backup copy of config from \""
+ cfgFile
+ "\" to \""
+ tmpBakCfgFile
+ "\"",
e
);
return; // Abort write
}
try {
atomicMove(tmpBakCfgFile, bakCfgFile);
} catch (IOException e) {
LogManager
.severe(
"Unable to move backup config from \""
+ tmpBakCfgFile
+ "\" to \""
+ bakCfgFile
+ "\"",
e
);
}
}
@ThreadSafe
public synchronized void saveConfig() {
Path tmpCfgFile = Paths.get(configPath + ".tmp");
Path cfgFile = Paths.get(configPath);
// Serialize config
try {
// delete accidental folder caused by PR
// https://github.com/SlimeVR/SlimeVR-Server/pull/1176
var cfgFileMaybeFolder = cfgFile.toFile();
if (cfgFileMaybeFolder.isDirectory()) {
try (Stream<Path> pathStream = Files.walk(cfgFile)) {
// Can't use .toList() on Android
var list = pathStream
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
for (var path : list) {
Files.delete(path);
}
} catch (IOException e) {
LogManager
.severe(
"Unable to delete folder that has same name as the config file on path \""
+ cfgFile
+ "\""
);
return;
}
}
var cfgFolder = cfgFile.toAbsolutePath().getParent().toFile();
if (!cfgFolder.exists() && !cfgFolder.mkdirs()) {
LogManager
.severe("Unable to create folders for config on path \"" + cfgFile + "\"");
return;
}
om.writeValue(tmpCfgFile.toFile(), this.vrConfig);
} catch (IOException e) {
LogManager.severe("Unable to write serialized config to \"" + tmpCfgFile + "\"", e);
return; // Abort write
}
// Overwrite old config
try {
atomicMove(tmpCfgFile, cfgFile);
} catch (IOException e) {
LogManager
.severe(
"Unable to move new config from \"" + tmpCfgFile + "\" to \"" + cfgFile + "\"",
e
);
}
}
public void resetConfig() {
this.vrConfig = new VRConfig();
saveConfig();
}
public VRConfig getVrConfig() {
return vrConfig;
}
}

View File

@@ -1,357 +0,0 @@
package dev.slimevr.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.*;
import com.github.jonpeterson.jackson.module.versioning.VersionedModelConverter;
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets;
import dev.slimevr.tracking.trackers.TrackerPosition;
import io.eiren.util.logging.LogManager;
import java.util.Map;
import java.util.regex.Pattern;
public class CurrentVRConfigConverter implements VersionedModelConverter {
@Override
public ObjectNode convert(
ObjectNode modelData,
String modelVersion,
String targetModelVersion,
JsonNodeFactory nodeFactory
) {
try {
int version = Integer.parseInt(modelVersion);
// Configs with old versions need a migration to the latest config
if (version < 2) {
// Move zoom to the window config
ObjectNode windowNode = (ObjectNode) modelData.get("window");
DoubleNode zoomNode = (DoubleNode) modelData.get("zoom");
if (windowNode != null && zoomNode != null) {
windowNode.set("zoom", zoomNode);
modelData.remove("zoom");
}
// Change trackers list to map
ArrayNode oldTrackersNode = modelData.withArray("trackers");
if (oldTrackersNode != null) {
var trackersIter = oldTrackersNode.iterator();
ObjectNode trackersNode = nodeFactory.objectNode();
while (trackersIter.hasNext()) {
JsonNode node = trackersIter.next();
JsonNode resultNode = TrackerConfig.toV2(node, nodeFactory);
trackersNode.set(node.get("name").asText(), resultNode);
}
modelData.set("trackers", trackersNode);
}
// Rename bridge to bridges
ObjectNode bridgeNode = (ObjectNode) modelData.get("bridge");
if (bridgeNode != null) {
modelData.set("bridges", bridgeNode);
modelData.remove("bridge");
}
// Move body to skeleton (and merge it to current skeleton)
ObjectNode bodyNode = (ObjectNode) modelData.get("body");
if (bodyNode != null) {
var bodyIter = bodyNode.fields();
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
if (skeletonNode == null) {
skeletonNode = nodeFactory.objectNode();
}
ObjectNode offsetsNode = nodeFactory.objectNode();
while (bodyIter.hasNext()) {
Map.Entry<String, JsonNode> node = bodyIter.next();
// Filter only number values because other types would
// be stuff that didn't get migrated correctly before
if (node.getValue().isNumber()) {
offsetsNode.set(node.getKey(), node.getValue());
}
}
// Fix calibration wolf typos
offsetsNode.set("shouldersWidth", bodyNode.get("shoulersWidth"));
offsetsNode.set("shouldersDistance", bodyNode.get("shoulersDistance"));
offsetsNode.remove("shoulersWidth");
offsetsNode.remove("shoulersDistance");
skeletonNode.set("offsets", offsetsNode);
modelData.set("skeleton", skeletonNode);
modelData.remove("body");
}
}
if (version < 3) {
// Check for out-of-bound filtering amount
ObjectNode filtersNode = (ObjectNode) modelData.get("filters");
if (filtersNode != null && filtersNode.get("amount").floatValue() > 2f) {
filtersNode.set("amount", new FloatNode(0.2f));
}
}
if (version < 4) {
// Change mountingRotation to mountingOrientation
ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers");
if (oldTrackersNode != null) {
var trackersIter = oldTrackersNode.iterator();
var fieldNamesIter = oldTrackersNode.fieldNames();
ObjectNode trackersNode = nodeFactory.objectNode();
String fieldName;
while (trackersIter.hasNext()) {
ObjectNode node = (ObjectNode) trackersIter.next();
fieldName = fieldNamesIter.next();
node.set("mountingOrientation", node.get("mountingRotation"));
node.remove("mountingRotation");
trackersNode.set(fieldName, node);
}
modelData.set("trackers", trackersNode);
}
}
if (version < 5) {
// Migrate old skeleton offsets to new ones
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
if (skeletonNode != null) {
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
if (offsetsNode != null) {
// torsoLength, chestDistance and waistDistance become
// chestLength, waistLength and hipLength.
float torsoLength = SkeletonConfigOffsets.CHEST.defaultValue
+ SkeletonConfigOffsets.WAIST.defaultValue
+ SkeletonConfigOffsets.HIP.defaultValue;
float chestDistance = SkeletonConfigOffsets.CHEST.defaultValue;
float waistDistance = SkeletonConfigOffsets.HIP.defaultValue;
JsonNode torsoNode = offsetsNode.get("torsoLength");
if (torsoNode != null)
torsoLength = torsoNode.floatValue();
JsonNode chestNode = offsetsNode.get("chestDistance");
if (chestNode != null)
chestDistance = chestNode.floatValue();
JsonNode waistNode = offsetsNode.get("waistDistance");
if (waistNode != null)
waistDistance = waistNode.floatValue();
offsetsNode.set("chestLength", offsetsNode.get("chestDistance"));
offsetsNode
.set(
"waistLength",
new FloatNode(torsoLength - chestDistance - waistDistance)
);
offsetsNode.set("hipLength", offsetsNode.get("waistDistance"));
offsetsNode.remove("torsoLength");
offsetsNode.remove("chestDistance");
offsetsNode.remove("waistDistance");
// legsLength and kneeHeight become
// upperLegLength and lowerLegLength
float legsLength = SkeletonConfigOffsets.UPPER_LEG.defaultValue
+ SkeletonConfigOffsets.LOWER_LEG.defaultValue;
float kneeHeight = SkeletonConfigOffsets.LOWER_LEG.defaultValue;
JsonNode legsNode = offsetsNode.get("legsLength");
if (legsNode != null)
legsLength = legsNode.floatValue();
JsonNode kneesNode = offsetsNode.get("kneeHeight");
if (kneesNode != null)
kneeHeight = kneesNode.floatValue();
offsetsNode.set("upperLegLength", new FloatNode(legsLength - kneeHeight));
offsetsNode.set("lowerLegLength", new FloatNode(kneeHeight));
offsetsNode.remove("legsLength");
offsetsNode.remove("kneeHeight");
skeletonNode.set("offsets", offsetsNode);
modelData.set("skeleton", skeletonNode);
}
}
}
if (version < 6) {
// Migrate controllers offsets to hands offsets
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
if (skeletonNode != null) {
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
if (offsetsNode != null) {
offsetsNode.set("handDistanceY", offsetsNode.get("controllerDistanceY"));
offsetsNode.set("handDistanceZ", offsetsNode.get("controllerDistanceZ"));
}
}
}
if (version < 7) {
// Chest, hip, and elbow offsets now go the opposite direction
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
if (skeletonNode != null) {
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
if (offsetsNode != null) {
JsonNode chestNode = offsetsNode.get("chestOffset");
if (chestNode != null)
offsetsNode.set("chestOffset", new FloatNode(-chestNode.floatValue()));
JsonNode hipNode = offsetsNode.get("hipOffset");
if (hipNode != null)
offsetsNode.set("hipOffset", new FloatNode(-hipNode.floatValue()));
JsonNode elbowNode = offsetsNode.get("elbowOffset");
if (elbowNode != null)
offsetsNode.set("elbowOffset", new FloatNode(-elbowNode.floatValue()));
}
}
}
if (version < 8) {
// reset > fullReset, quickReset > yawReset
ObjectNode keybindingsNode = (ObjectNode) modelData.get("keybindings");
if (keybindingsNode != null) {
JsonNode fullResetNode = keybindingsNode.get("resetBinding");
if (fullResetNode != null)
keybindingsNode.set("fullResetBinding", fullResetNode);
JsonNode yawResetNode = keybindingsNode.get("quickResetBinding");
if (yawResetNode != null)
keybindingsNode.set("yawResetBinding", yawResetNode);
JsonNode mountingResetNode = keybindingsNode.get("resetMountingBinding");
if (mountingResetNode != null)
keybindingsNode.set("mountingResetBinding", mountingResetNode);
JsonNode fullDelayNode = keybindingsNode.get("resetDelay");
if (fullDelayNode != null)
keybindingsNode.set("fullResetDelay", fullDelayNode);
JsonNode yawDelayNode = keybindingsNode.get("quickResetDelay");
if (yawDelayNode != null)
keybindingsNode.set("yawResetDelay", yawDelayNode);
JsonNode mountingDelayNode = keybindingsNode.get("resetMountingDelay");
if (mountingDelayNode != null)
keybindingsNode.set("mountingResetDelay", mountingDelayNode);
}
ObjectNode tapDetectionNode = (ObjectNode) modelData.get("tapDetection");
if (tapDetectionNode != null) {
tapDetectionNode.set("yawResetDelay", tapDetectionNode.get("quickResetDelay"));
tapDetectionNode.set("fullResetDelay", tapDetectionNode.get("resetDelay"));
tapDetectionNode
.set("yawResetEnabled", tapDetectionNode.get("quickResetEnabled"));
tapDetectionNode.set("fullResetEnabled", tapDetectionNode.get("resetEnabled"));
tapDetectionNode.set("yawResetTaps", tapDetectionNode.get("quickResetTaps"));
tapDetectionNode.set("fullResetTaps", tapDetectionNode.get("resetTaps"));
}
}
if (version < 9) {
// split chest into 2 offsets
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
if (skeletonNode != null) {
ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets");
if (offsetsNode != null) {
JsonNode chestNode = offsetsNode.get("chestLength");
if (chestNode != null) {
offsetsNode
.set("chestLength", new FloatNode(chestNode.floatValue() / 2f));
offsetsNode
.set(
"upperChestLength",
new FloatNode(chestNode.floatValue() / 2f)
);
}
}
}
}
if (version < 10) {
// Change default AutoBone recording length from 20 to 30
// seconds
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
if (autoBoneNode != null) {
JsonNode sampleCountNode = autoBoneNode.get("sampleCount");
if (sampleCountNode != null && sampleCountNode.intValue() == 1000) {
autoBoneNode.set("sampleCount", new IntNode(1500));
}
}
}
if (version < 11) {
// Sets HMD's designation to "body:head"
ObjectNode trackersNode = (ObjectNode) modelData.get("trackers");
if (trackersNode != null) {
ObjectNode HMDNode = (ObjectNode) trackersNode.get("HMD");
if (HMDNode != null) {
HMDNode
.set(
"designation",
new TextNode(TrackerPosition.HEAD.getDesignation())
);
trackersNode.set("HMD", HMDNode);
modelData.set("trackers", trackersNode);
}
}
}
if (version < 12) {
// Update AutoBone defaults
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
if (autoBoneNode != null) {
JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor");
if (offsetSlideNode != null && offsetSlideNode.floatValue() == 2.0f) {
autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(1.0f));
}
JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor");
if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.825f) {
autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.25f));
}
}
}
if (version < 13) {
ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers");
if (oldTrackersNode != null) {
var fieldNamesIter = oldTrackersNode.fieldNames();
String trackerId;
final String macAddressRegex = "udp://((?:[a-zA-Z\\d]{2}:){5}[a-zA-Z\\d]{2})/0";
final Pattern pattern = Pattern.compile(macAddressRegex);
while (fieldNamesIter.hasNext()) {
trackerId = fieldNamesIter.next();
var matcher = pattern.matcher(trackerId);
if (!matcher.find())
continue;
modelData.withArray("knownDevices").add(matcher.group(1));
}
}
}
if (version < 14) {
// Update AutoBone defaults
ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone");
if (autoBoneNode != null) {
// Move HMD height to skeleton
ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton");
if (skeletonNode != null) {
JsonNode targetHmdHeight = autoBoneNode.get("targetHmdHeight");
if (targetHmdHeight != null) {
skeletonNode.set("hmdHeight", targetHmdHeight);
}
}
JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor");
JsonNode slideNode = autoBoneNode.get("slideErrorFactor");
if (
offsetSlideNode != null
&& slideNode != null
&& offsetSlideNode.floatValue() == 1.0f
&& slideNode.floatValue() == 0.0f
) {
autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(0.0f));
autoBoneNode.set("slideErrorFactor", new FloatNode(1.0f));
}
JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor");
if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.25f) {
autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.05f));
}
JsonNode numEpochsNode = autoBoneNode.get("numEpochs");
if (numEpochsNode != null && numEpochsNode.intValue() == 100) {
autoBoneNode.set("numEpochs", new IntNode(50));
}
}
}
if (version < 15) {
ObjectNode checklistNode = (ObjectNode) modelData.get("trackingChecklist");
if (checklistNode != null) {
ArrayNode ignoredStepsArray = (ArrayNode) checklistNode.get("ignoredStepsIds");
if (ignoredStepsArray != null)
ignoredStepsArray.removeAll();
}
}
} catch (Exception e) {
LogManager.severe("Error during config migration: " + e);
}
return modelData;
}
}

View File

@@ -1,26 +0,0 @@
package dev.slimevr.config
import dev.slimevr.VRServer
class DriftCompensationConfig {
// Is drift compensation enabled
var enabled = false
// Is drift prediction enabled
var prediction = false
// Amount of drift compensation applied
var amount = 0.8f
// Max resets for the calculated average drift
var maxResets = 6
fun updateTrackersDriftCompensation() {
for (t in VRServer.instance.allTrackers) {
if (t.isImu()) {
t.resetsHandler.readDriftCompensationConfig(this)
}
}
}
}

View File

@@ -1,20 +0,0 @@
package dev.slimevr.config
import dev.slimevr.VRServer
class FiltersConfig {
// Type of filtering applied (none, smoothing or prediction)
var type = "prediction"
// Amount/Intensity of the specified filtering (0 to 1)
var amount = 0.2f
fun updateTrackersFilters() {
for (tracker in VRServer.instance.allTrackers) {
if (tracker.allowFiltering) {
tracker.filteringHandler.readFilteringConfig(this, tracker.getRotation())
}
}
}
}

View File

@@ -1,7 +0,0 @@
package dev.slimevr.config
import com.fasterxml.jackson.annotation.JsonIgnore
class HIDConfig {
var trackersOverHID = false
}

View File

@@ -1,88 +0,0 @@
package dev.slimevr.config;
public class KeybindingsConfig {
private String fullResetBinding = "CTRL+ALT+SHIFT+Y";
private String yawResetBinding = "CTRL+ALT+SHIFT+U";
private String mountingResetBinding = "CTRL+ALT+SHIFT+I";
private String feetMountingResetBinding = "CTRL+ALT+SHIFT+P";
private String pauseTrackingBinding = "CTRL+ALT+SHIFT+O";
private long fullResetDelay = 0L;
private long yawResetDelay = 0L;
private long mountingResetDelay = 0L;
private long feetMountingResetDelay = 0L;
private long pauseTrackingDelay = 0L;
public KeybindingsConfig() {
}
public String getFullResetBinding() {
return fullResetBinding;
}
public String getYawResetBinding() {
return yawResetBinding;
}
public String getMountingResetBinding() {
return mountingResetBinding;
}
public String getFeetMountingResetBinding() {
return feetMountingResetBinding;
}
public String getPauseTrackingBinding() {
return pauseTrackingBinding;
}
public long getFullResetDelay() {
return fullResetDelay;
}
public void setFullResetDelay(long delay) {
fullResetDelay = delay;
}
public long getYawResetDelay() {
return yawResetDelay;
}
public void setYawResetDelay(long delay) {
yawResetDelay = delay;
}
public long getMountingResetDelay() {
return mountingResetDelay;
}
public void setMountingResetDelay(long delay) {
mountingResetDelay = delay;
}
public long getFeetMountingResetDelay() {
return feetMountingResetDelay;
}
public void setFeetMountingResetDelay(long delay) {
feetMountingResetDelay = delay;
}
public long getPauseTrackingDelay() {
return pauseTrackingDelay;
}
public void setPauseTrackingDelay(long delay) {
pauseTrackingDelay = delay;
}
}

View File

@@ -1,6 +0,0 @@
package dev.slimevr.config
class LegTweaksConfig {
var correctionStrength = 0.3f
var alwaysUseFloorclip = false
}

View File

@@ -1,16 +0,0 @@
package dev.slimevr.config
open class OSCConfig {
// Are the OSC receiver and sender enabled?
var enabled = false
// Port to receive OSC messages from
var portIn = 0
// Port to send out OSC messages at
var portOut = 0
// Address to send out OSC messages at
var address = "127.0.0.1"
}

View File

@@ -1,24 +0,0 @@
package dev.slimevr.config;
public class OverlayConfig {
private boolean isMirrored = false;
private boolean isVisible = false;
public boolean isMirrored() {
return isMirrored;
}
public boolean isVisible() {
return isVisible;
}
public void setMirrored(boolean mirrored) {
isMirrored = mirrored;
}
public void setVisible(boolean visible) {
isVisible = visible;
}
}

View File

@@ -1,78 +0,0 @@
package dev.slimevr.config
import dev.slimevr.VRServer
enum class ArmsResetModes(val id: Int) {
// Upper arm going back and forearm going forward
BACK(0),
// Arms going forward
FORWARD(1),
// Arms going up to the sides into a tpose
TPOSE_UP(2),
// Arms going down to the sides from a tpose
TPOSE_DOWN(3),
;
companion object {
val values = entries.toTypedArray()
@JvmStatic
fun fromId(id: Int): ArmsResetModes? {
for (filter in values) {
if (filter.id == id) return filter
}
return null
}
}
}
enum class MountingMethods(val id: Int) {
MANUAL(0),
AUTOMATIC(1),
;
companion object {
val values = MountingMethods.entries.toTypedArray()
@JvmStatic
fun fromId(id: Int): MountingMethods? {
for (filter in values) {
if (filter.id == id) return filter
}
return null
}
}
}
class ResetsConfig {
// Always reset mounting for feet
var resetMountingFeet = false
// Reset mode used for the arms
var mode = ArmsResetModes.BACK
// Yaw reset smoothing time in seconds
var yawResetSmoothTime = 0.0f
// Save automatic mounting reset calibration
var saveMountingReset = false
// Reset the HMD's pitch upon full reset
var resetHmdPitch = false
var lastMountingMethod = MountingMethods.AUTOMATIC
var yawResetDelay = 0.0f
var fullResetDelay = 3.0f
var mountingResetDelay = 3.0f
fun updateTrackersResetsSettings() {
for (t in VRServer.instance.allTrackers) {
t.resetsHandler.readResetConfig(this)
}
}
}

View File

@@ -1,66 +0,0 @@
package dev.slimevr.config
import dev.slimevr.VRServer
import dev.slimevr.tracking.trackers.udp.MagnetometerStatus
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
class ServerConfig {
val trackerPort: Int = 6969
var useMagnetometerOnAllTrackers: Boolean = false
private set
private val magMutex = Mutex()
suspend fun defineMagOnAllTrackers(state: Boolean) = coroutineScope {
magMutex.lock()
try {
if (useMagnetometerOnAllTrackers == state) return@coroutineScope
VRServer.instance.deviceManager.devices.filter { it.magSupport }.map {
async {
// Not using 255 as it sometimes could make one of the sensors go into
// error mode (if there is more than one sensor inside the device)
if (!state) {
val trackers = it.trackers.filterValues {
it.magStatus != MagnetometerStatus.NOT_SUPPORTED
}
// if(trackers.size == it.trackers.size) {
// it.setMag(false)
// } else {
trackers.map { (_, t) ->
async { it.setMag(false, t.trackerNum) }
}.awaitAll()
// }
return@async
}
// val every = it.trackers.all { (_, t) -> t.config.shouldHaveMagEnabled == true
// && t.magStatus != MagnetometerStatus.NOT_SUPPORTED }
// if (every) {
// it.setMag(true)
// return@async
// }
it.trackers.filterValues {
it.config.shouldHaveMagEnabled == true &&
it.magStatus != MagnetometerStatus.NOT_SUPPORTED
}
.map { (_, t) ->
async {
// FIXME: Tracker gets restarted after each setMag, what will happen for devices with 3 trackers?
it.setMag(true, t.trackerNum)
}
}.awaitAll()
}
}.awaitAll()
useMagnetometerOnAllTrackers = state
VRServer.instance.configManager.saveConfig()
} finally {
magMutex.unlock()
}
}
}

View File

@@ -1,63 +0,0 @@
package dev.slimevr.config;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers;
import dev.slimevr.config.serializers.BooleanMapDeserializer;
import dev.slimevr.config.serializers.FloatMapDeserializer;
import java.util.HashMap;
import java.util.Map;
public class SkeletonConfig {
@JsonDeserialize(using = BooleanMapDeserializer.class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
public Map<String, Boolean> toggles = new HashMap<>();
@JsonDeserialize(using = FloatMapDeserializer.class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
public Map<String, Float> values = new HashMap<>();
@JsonDeserialize(using = FloatMapDeserializer.class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
public Map<String, Float> offsets = new HashMap<>();
private float hmdHeight = 0f;
private float floorHeight = 0f;
public Map<String, Boolean> getToggles() {
return toggles;
}
public Map<String, Float> getOffsets() {
return offsets;
}
public Map<String, Float> getValues() {
return values;
}
public float getHmdHeight() {
return hmdHeight;
}
public void setHmdHeight(float hmdHeight) {
this.hmdHeight = hmdHeight;
}
public float getFloorHeight() {
return floorHeight;
}
public void setFloorHeight(float hmdHeight) {
this.floorHeight = hmdHeight;
}
@JsonIgnore
public float getUserHeight() {
return hmdHeight - floorHeight;
}
}

View File

@@ -1,44 +0,0 @@
package dev.slimevr.config
import com.fasterxml.jackson.annotation.JsonIgnore
class StayAlignedConfig {
/**
* Apply yaw correction
*/
var enabled = false
/**
* Temporarily hide the yaw correction from Stay Aligned.
*
* Players can enable this to compare to when Stay Aligned is not enabled. Useful to
* verify if Stay Aligned improved the situation. Also useful to prevent players
* from saying "Stay Aligned screwed up my trackers!!" when it's actually a tracker
* that is drifting extremely badly.
*
* Do not serialize to config so that when the server restarts, it is always false.
*/
@JsonIgnore
var hideYawCorrection = false
/**
* Standing relaxed pose
*/
val standingRelaxedPose = StayAlignedRelaxedPoseConfig()
/**
* Sitting relaxed pose
*/
val sittingRelaxedPose = StayAlignedRelaxedPoseConfig()
/**
* Flat relaxed pose
*/
val flatRelaxedPose = StayAlignedRelaxedPoseConfig()
/**
* Whether setup has been completed
*/
var setupComplete = false
}

View File

@@ -1,25 +0,0 @@
package dev.slimevr.config
class StayAlignedRelaxedPoseConfig {
/**
* Whether Stay Aligned should adjust the tracker yaws when the player is in this
* pose.
*/
var enabled = false
/**
* Angle between the upper leg yaw and the center yaw.
*/
var upperLegAngleInDeg = 0.0f
/**
* Angle between the lower leg yaw and the center yaw.
*/
var lowerLegAngleInDeg = 0.0f
/**
* Angle between the foot and the center yaw.
*/
var footAngleInDeg = 0.0f
}

View File

@@ -1,29 +0,0 @@
package dev.slimevr.config
import com.jme3.math.FastMath
// handles the tap detection config
// this involves the number of taps, the delay, and whether or not the feature is enabled
// for each reset type
class TapDetectionConfig {
var yawResetDelay = 0.2f
var fullResetDelay = 1.0f
var mountingResetDelay = 1.0f
var yawResetEnabled = true
var fullResetEnabled = true
var mountingResetEnabled = true
var setupMode = false
var yawResetTaps = 2
set(yawResetTaps) {
field = yawResetTaps.coerceIn(2, 10)
}
var fullResetTaps = 3
set(fullResetTaps) {
field = fullResetTaps.coerceIn(2, 10)
}
var mountingResetTaps = 3
set(mountingResetTaps) {
field = mountingResetTaps.coerceIn(2, 10)
}
var numberTrackersOverThreshold = 1
}

View File

@@ -1,49 +0,0 @@
package dev.slimevr.config
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.JsonNodeFactory
import dev.slimevr.VRServer
import dev.slimevr.tracking.trackers.Tracker
import io.github.axisangles.ktmath.ObjectQuaternion
class TrackerConfig {
var customName: String? = null
var designation: String? = null
@get:JvmName("isHide")
var hide: Boolean = false
var adjustment: ObjectQuaternion? = null
var mountingOrientation: ObjectQuaternion? = null
var mountingResetOrientation: ObjectQuaternion? = null
var allowDriftCompensation: Boolean? = null
/**
* Only checked if [ServerConfig.useMagnetometerOnAllTrackers] enabled
*/
var shouldHaveMagEnabled: Boolean? = null
constructor()
constructor(tracker: Tracker) {
this.designation = if (tracker.trackerPosition != null) tracker.trackerPosition!!.designation else null
this.customName = tracker.customName
allowDriftCompensation = tracker.isImu()
shouldHaveMagEnabled = tracker.isImu()
}
companion object {
@JvmStatic
fun toV2(v1: JsonNode, factory: JsonNodeFactory): JsonNode {
val node = factory.objectNode()
if (v1.has("customName")) node.set<JsonNode>("customName", v1["customName"])
if (v1.has("designation")) node.set<JsonNode>("designation", v1["designation"])
if (v1.has("hide")) node.set<JsonNode>("hide", v1["hide"])
if (v1.has("mountingRotation")) node.set<JsonNode>("mountingRotation", v1["mountingRotation"])
if (v1.has("adjustment")) node.set<JsonNode>("adjustment", v1["adjustment"])
return node
}
}
}
val Tracker.config: TrackerConfig
get() = VRServer.instance.configManager.vrConfig.getTracker(this)

View File

@@ -1,5 +0,0 @@
package dev.slimevr.config
class TrackingChecklistConfig {
val ignoredStepsIds: MutableList<Int> = mutableListOf()
}

View File

@@ -1,13 +0,0 @@
package dev.slimevr.config
class VMCConfig : OSCConfig() {
// Anchor the tracking at the hip?
var anchorHip = true
// JSON part of the VRM to be used
var vrmJson: String? = null
// Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg)
var mirrorTracking = false
}

View File

@@ -1,6 +0,0 @@
package dev.slimevr.config
class VRCConfig {
// List of fields ignored in vrc warnings - @see VRCConfigValidity
val mutedWarnings: MutableList<String> = mutableListOf()
}

View File

@@ -1,24 +0,0 @@
package dev.slimevr.config
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers
import dev.slimevr.config.serializers.BooleanMapDeserializer
import dev.slimevr.tracking.trackers.TrackerRole
import java.util.*
class VRCOSCConfig : OSCConfig() {
// Which trackers' data to send
@JsonDeserialize(using = BooleanMapDeserializer::class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class)
var trackers: MutableMap<String, Boolean> = HashMap()
var oscqueryEnabled: Boolean = true
fun getOSCTrackerRole(role: TrackerRole, def: Boolean): Boolean = trackers.getOrDefault(role.name.lowercase(Locale.getDefault()), def)
fun setOSCTrackerRole(role: TrackerRole, `val`: Boolean) {
trackers[role.name.lowercase(Locale.getDefault())] = `val`
}
}

View File

@@ -1,145 +0,0 @@
package dev.slimevr.config
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.databind.annotation.JsonSerialize
import com.fasterxml.jackson.databind.ser.std.StdKeySerializers
import com.github.jonpeterson.jackson.module.versioning.JsonVersionedModel
import dev.slimevr.config.serializers.BridgeConfigMapDeserializer
import dev.slimevr.config.serializers.TrackerConfigMapDeserializer
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerRole
@JsonVersionedModel(
currentVersion = "15",
defaultDeserializeToVersion = "15",
toCurrentConverterClass = CurrentVRConfigConverter::class,
)
class VRConfig {
val server: ServerConfig = ServerConfig()
val filters: FiltersConfig = FiltersConfig()
val driftCompensation: DriftCompensationConfig = DriftCompensationConfig()
val oscRouter: OSCConfig = OSCConfig()
val vrcOSC: VRCOSCConfig = VRCOSCConfig()
@get:JvmName("getVMC")
val vmc: VMCConfig = VMCConfig()
val autoBone: AutoBoneConfig = AutoBoneConfig()
val keybindings: KeybindingsConfig = KeybindingsConfig()
val skeleton: SkeletonConfig = SkeletonConfig()
val legTweaks: LegTweaksConfig = LegTweaksConfig()
val tapDetection: TapDetectionConfig = TapDetectionConfig()
val resetsConfig: ResetsConfig = ResetsConfig()
val stayAlignedConfig = StayAlignedConfig()
val hidConfig = HIDConfig()
@JsonDeserialize(using = TrackerConfigMapDeserializer::class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class)
private val trackers: MutableMap<String, TrackerConfig> = HashMap()
@JsonDeserialize(using = BridgeConfigMapDeserializer::class)
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class)
private val bridges: MutableMap<String, BridgeConfig> = HashMap()
val knownDevices: MutableSet<String> = mutableSetOf()
val overlay: OverlayConfig = OverlayConfig()
val trackingChecklist: TrackingChecklistConfig = TrackingChecklistConfig()
val vrcConfig: VRCConfig = VRCConfig()
init {
// Initialize default settings for OSC Router
oscRouter.portIn = 9002
oscRouter.portOut = 9000
// Initialize default settings for VRC OSC
vrcOSC.portIn = 9001
vrcOSC.portOut = 9000
vrcOSC
.setOSCTrackerRole(
TrackerRole.WAIST,
vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true),
)
vrcOSC
.setOSCTrackerRole(
TrackerRole.LEFT_FOOT,
vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true),
)
vrcOSC
.setOSCTrackerRole(
TrackerRole.RIGHT_FOOT,
vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true),
)
// Initialize default settings for VMC
vmc.portIn = 39540
vmc.portOut = 39539
}
fun getTrackers(): Map<String, TrackerConfig> = trackers
fun getBridges(): Map<String, BridgeConfig> = bridges
fun hasTrackerByName(name: String): Boolean = trackers.containsKey(name)
fun getTracker(tracker: Tracker): TrackerConfig {
var config = trackers[tracker.name]
if (config == null) {
config = TrackerConfig(tracker)
trackers[tracker.name] = config
}
return config
}
fun readTrackerConfig(tracker: Tracker) {
if (tracker.userEditable) {
val config = getTracker(tracker)
tracker.readConfig(config)
if (tracker.isImu()) tracker.resetsHandler.readDriftCompensationConfig(driftCompensation)
tracker.resetsHandler.readResetConfig(resetsConfig)
if (tracker.allowReset) {
tracker.saveMountingResetOrientation(config)
}
if (tracker.allowFiltering) {
tracker
.filteringHandler
.readFilteringConfig(filters, tracker.getRotation())
}
}
}
fun writeTrackerConfig(tracker: Tracker?) {
if (tracker?.userEditable == true) {
val tc = getTracker(tracker)
tracker.writeConfig(tc)
}
}
fun getBridge(bridgeKey: String): BridgeConfig {
var config = bridges[bridgeKey]
if (config == null) {
config = BridgeConfig()
bridges[bridgeKey] = config
}
return config
}
fun isKnownDevice(mac: String?): Boolean = knownDevices.contains(mac)
fun addKnownDevice(mac: String): Boolean = knownDevices.add(mac)
fun forgetKnownDevice(mac: String): Boolean = knownDevices.remove(mac)
}

View File

@@ -1,15 +0,0 @@
package dev.slimevr.config.serializers;
/**
* This class allows the use of the utility super class MapDeserializer that
* takes the Value of a map as its Generic parameter. It is so you can use that
* class in a @JsonDeserialize annotation on the Map field inside the config
* instance
*
* @see dev.slimevr.config.VRConfig
*/
public class BooleanMapDeserializer extends MapDeserializer<Boolean> {
public BooleanMapDeserializer() {
super(Boolean.class);
}
}

View File

@@ -1,18 +0,0 @@
package dev.slimevr.config.serializers;
import dev.slimevr.config.BridgeConfig;
/**
* This class allows the use of the utility super class MapDeserializer that
* takes the Value of a map as its Generic parameter. It is so you can use that
* class in a @JsonDeserialize annotation on the Map field inside the config
* instance
*
* @see dev.slimevr.config.VRConfig
*/
public class BridgeConfigMapDeserializer extends MapDeserializer<BridgeConfig> {
public BridgeConfigMapDeserializer() {
super(BridgeConfig.class);
}
}

View File

@@ -1,15 +0,0 @@
package dev.slimevr.config.serializers;
/**
* This class allows the use of the utility super class MapDeserializer that
* takes the Value of a map as its Generic parameter. It is so you can use that
* class in a @JsonDeserialize annotation on the Map field inside the config
* instance
*
* @see dev.slimevr.config.VRConfig
*/
public class FloatMapDeserializer extends MapDeserializer<Float> {
public FloatMapDeserializer() {
super(Float.class);
}
}

View File

@@ -1,36 +0,0 @@
package dev.slimevr.config.serializers;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.type.MapType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import java.io.IOException;
import java.util.HashMap;
/**
* This class is a utility class that allows to write Map serializers easily to
* be used in the VRConfig (@see {@link dev.slimevr.config.VRConfig})
*
* @see BooleanMapDeserializer to see how it is used
*/
public abstract class MapDeserializer<T> extends JsonDeserializer<HashMap<String, T>> {
private final Class<T> valueClass;
public MapDeserializer(Class<T> valueClass) {
super();
this.valueClass = valueClass;
}
@Override
public HashMap<String, T> deserialize(JsonParser p, DeserializationContext dc)
throws IOException {
TypeFactory typeFactory = dc.getTypeFactory();
MapType mapType = typeFactory
.constructMapType(HashMap.class, String.class, valueClass);
return dc.readValue(p, mapType);
}
}

View File

@@ -1,26 +0,0 @@
package dev.slimevr.config.serializers;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import io.github.axisangles.ktmath.ObjectQuaternion;
import java.io.IOException;
public class QuaternionDeserializer extends JsonDeserializer<ObjectQuaternion> {
@Override
public ObjectQuaternion deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException, JacksonException {
JsonNode node = p.getCodec().readTree(p);
return new ObjectQuaternion(
(float) node.get("w").asDouble(),
(float) node.get("x").asDouble(),
(float) node.get("y").asDouble(),
(float) node.get("z").asDouble()
);
}
}

View File

@@ -1,23 +0,0 @@
package dev.slimevr.config.serializers;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import io.github.axisangles.ktmath.ObjectQuaternion;
import java.io.IOException;
public class QuaternionSerializer extends JsonSerializer<ObjectQuaternion> {
@Override
public void serialize(ObjectQuaternion value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeStartObject();
gen.writeNumberField("x", value.getX());
gen.writeNumberField("y", value.getY());
gen.writeNumberField("z", value.getZ());
gen.writeNumberField("w", value.getW());
gen.writeEndObject();
}
}

View File

@@ -1,18 +0,0 @@
package dev.slimevr.config.serializers;
import dev.slimevr.config.TrackerConfig;
/**
* This class allows the use of the utility super class MapDeserializer that
* takes the Value of a map as its Generic parameter. It is so you can use that
* class in a @JsonDeserialize annotation on the Map field inside the config
* instance
*
* @see dev.slimevr.config.VRConfig
*/
public class TrackerConfigMapDeserializer extends MapDeserializer<TrackerConfig> {
public TrackerConfigMapDeserializer() {
super(TrackerConfig.class);
}
}

View File

@@ -1,117 +0,0 @@
package dev.slimevr.filtering;
import java.util.*;
/**
* If you use this code, please consider notifying isak at du-preez dot com with
* a brief description of your application.
* <p>
* This is free and unencumbered software released into the public domain.
* Anyone is free to copy, modify, publish, use, compile, sell, or distribute
* this software, either in source code form or as a compiled binary, for any
* purpose, commercial or non-commercial, and by any means.
*/
public class CircularArrayList<E> extends AbstractList<E> implements RandomAccess {
private final int n; // buffer length
private final List<E> buf; // a List implementing RandomAccess
private int head = 0;
private int tail = 0;
public CircularArrayList(int capacity) {
n = capacity + 1;
buf = new ArrayList<>(Collections.nCopies(n, null));
}
public int capacity() {
return n - 1;
}
private int wrapIndex(int i) {
int m = i % n;
if (m < 0) { // java modulus can be negative
m += n;
}
return m;
}
// This method is O(n) but will never be called if the
// CircularArrayList is used in its typical/intended role.
private void shiftBlock(int startIndex, int endIndex) {
assert (endIndex > startIndex);
for (int i = endIndex - 1; i >= startIndex; i--) {
set(i + 1, get(i));
}
}
@Override
public int size() {
return tail - head + (tail < head ? n : 0);
}
@Override
public E get(int i) {
if (i < 0 || i >= size()) {
throw new IndexOutOfBoundsException();
}
return buf.get(wrapIndex(head + i));
}
public E getLatest() {
return buf.get(wrapIndex(head + size() - 1));
}
@Override
public E set(int i, E e) {
if (i < 0 || i >= size()) {
throw new IndexOutOfBoundsException();
}
return buf.set(wrapIndex(head + i), e);
}
@Override
public void add(int i, E e) {
int s = size();
if (s == n - 1) {
throw new IllegalStateException(
"CircularArrayList is filled to capacity. "
+ "(You may want to remove from front"
+ " before adding more to back.)"
);
}
if (i < 0 || i > s) {
throw new IndexOutOfBoundsException();
}
tail = wrapIndex(tail + 1);
if (i < s) {
shiftBlock(i, s);
}
set(i, e);
}
@Override
public E remove(int i) {
int s = size();
if (i < 0 || i >= s) {
throw new IndexOutOfBoundsException();
}
E e = get(i);
if (i > 0) {
shiftBlock(0, i);
}
head = wrapIndex(head + 1);
return e;
}
public E removeLast() {
int s = size();
if (0 == s) {
throw new IndexOutOfBoundsException();
}
E e = get(0);
head = wrapIndex(head + 1);
return e;
}
}

View File

@@ -1,127 +0,0 @@
package dev.slimevr.filtering
import com.jme3.system.NanoTimer
import dev.slimevr.VRServer
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY
// influences the range of smoothFactor.
private const val SMOOTH_MULTIPLIER = 42f
private const val SMOOTH_MIN = 11f
// influences the range of predictFactor
private const val PREDICT_MULTIPLIER = 15f
private const val PREDICT_MIN = 10f
// how many past rotations are used for prediction.
private const val PREDICT_BUFFER = 6
class QuaternionMovingAverage(
val type: TrackerFilters,
var amount: Float = 0f,
initialRotation: Quaternion = IDENTITY,
) {
var filteredQuaternion = IDENTITY
var filteringImpact = 0f
private var smoothFactor = 0f
private var predictFactor = 0f
private var rotBuffer: CircularArrayList<Quaternion>? = null
private var latestQuaternion = IDENTITY
private var smoothingQuaternion = IDENTITY
private val fpsTimer = if (VRServer.instanceInitialized) VRServer.instance.fpsTimer else NanoTimer()
private var timeSinceUpdate = 0f
init {
// amount should range from 0 to 1.
// GUI should clamp it from 0.01 (1%) or 0.1 (10%)
// to 1 (100%).
amount = amount.coerceAtLeast(0f)
if (type == TrackerFilters.SMOOTHING) {
// lower smoothFactor = more smoothing
smoothFactor = SMOOTH_MULTIPLIER * (1 - amount.coerceAtMost(1f)) + SMOOTH_MIN
// Totally a hack
if (amount > 1) {
smoothFactor /= amount
}
}
if (type == TrackerFilters.PREDICTION) {
// higher predictFactor = more prediction
predictFactor = PREDICT_MULTIPLIER * amount + PREDICT_MIN
rotBuffer = CircularArrayList(PREDICT_BUFFER)
}
// We have no reference at the start, so just use the initial rotation
resetQuats(initialRotation, initialRotation)
}
// Runs at up to 1000hz. We use a timer to make it framerate-independent
// since it runs a bit below 1000hz in practice.
@Synchronized
fun update() {
if (type == TrackerFilters.PREDICTION) {
val rotBuf = rotBuffer
if (rotBuf != null && rotBuf.isNotEmpty()) {
// Applies the past rotations to the current rotation
val predictRot = rotBuf.fold(latestQuaternion) { buf, rot -> buf * rot }
// Calculate how much to slerp
// Limit slerp by a reasonable amount so low TPS doesn't break tracking
val amt = (predictFactor * fpsTimer.timePerFrame).coerceAtMost(1f)
// Slerps the target rotation to that predicted rotation by amt
filteredQuaternion = filteredQuaternion.interpQ(predictRot, amt)
}
} else if (type == TrackerFilters.SMOOTHING) {
// Make it framerate-independent
timeSinceUpdate += fpsTimer.timePerFrame
// Calculate the slerp factor based off the smoothFactor and smoothingCounter
// limit to 1 to not overshoot
val amt = (smoothFactor * timeSinceUpdate).coerceAtMost(1f)
// Smooth towards the target rotation by the slerp factor
filteredQuaternion = smoothingQuaternion.interpQ(latestQuaternion, amt)
}
filteringImpact = latestQuaternion.angleToR(filteredQuaternion)
}
@Synchronized
fun addQuaternion(q: Quaternion) {
val oldQ = latestQuaternion
val newQ = q.twinNearest(oldQ)
latestQuaternion = newQ
if (type == TrackerFilters.PREDICTION) {
if (rotBuffer!!.size == rotBuffer!!.capacity()) {
rotBuffer?.removeLast()
}
// Gets and stores the rotation between the last 2 quaternions
rotBuffer?.add(oldQ.inv().times(newQ))
} else if (type == TrackerFilters.SMOOTHING) {
timeSinceUpdate = 0f
smoothingQuaternion = filteredQuaternion
} else {
// No filtering; just keep track of rotations (for going over 180 degrees)
filteredQuaternion = newQ
}
}
/**
* Aligns the quaternion space of [q] to the [reference] and sets the latest
* [filteredQuaternion] immediately
*/
@Synchronized
fun resetQuats(q: Quaternion, reference: Quaternion) {
// Assume a rotation within 180 degrees of the reference
// TODO: Currently the reference is the headset, this restricts all trackers to
// have at most a 180 degree rotation from the HMD during a reset, we can
// probably do better using a hierarchy
val rot = q.twinNearest(reference)
rotBuffer?.clear()
latestQuaternion = rot
filteredQuaternion = rot
addQuaternion(rot)
}
}

View File

@@ -1,34 +0,0 @@
package dev.slimevr.filtering
import java.util.*
enum class TrackerFilters(val id: Int, val configKey: String) {
NONE(0, "none"),
SMOOTHING(1, "smoothing"),
PREDICTION(2, "prediction"),
;
companion object {
private val byConfigkey: MutableMap<String, TrackerFilters> = HashMap()
init {
for (configVal in values()) {
byConfigkey[configVal.configKey.lowercase(Locale.getDefault())] =
configVal
}
}
val values = values()
@JvmStatic
fun fromId(id: Int): TrackerFilters? {
for (filter in values) {
if (filter.id == id) return filter
}
return null
}
@JvmStatic
fun getByConfigkey(configKey: String?): TrackerFilters? = if (configKey == null) null else byConfigkey[configKey.lowercase(Locale.getDefault())]
}
}

View File

@@ -1,539 +0,0 @@
package dev.slimevr.firmware
import com.mayakapps.kache.InMemoryKache
import com.mayakapps.kache.KacheStrategy
import dev.llelievr.espflashkotlin.Flasher
import dev.llelievr.espflashkotlin.FlashingProgressListener
import dev.slimevr.VRServer
import dev.slimevr.serial.ProvisioningListener
import dev.slimevr.serial.ProvisioningStatus
import dev.slimevr.serial.SerialPort
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerStatus
import dev.slimevr.tracking.trackers.TrackerStatusListener
import dev.slimevr.tracking.trackers.udp.UDPDevice
import io.eiren.util.logging.LogManager
import kotlinx.coroutines.*
import solarxr_protocol.rpc.FirmwarePartT
import solarxr_protocol.rpc.FirmwareUpdateRequestT
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.URL
import java.security.MessageDigest
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.stream.Collectors
import kotlin.concurrent.scheduleAtFixedRate
data class DownloadedFirmwarePart(
val firmware: ByteArray,
val offset: Long?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadedFirmwarePart
if (!firmware.contentEquals(other.firmware)) return false
if (offset != other.offset) return false
return true
}
override fun hashCode(): Int {
var result = firmware.contentHashCode()
result = 31 * result + (offset?.hashCode() ?: 0)
return result
}
}
class FirmwareUpdateHandler(private val server: VRServer) :
TrackerStatusListener,
ProvisioningListener,
SerialRebootListener {
private val updateTickTimer = Timer("StatusUpdateTimer")
private val runningJobs: MutableList<Job> = CopyOnWriteArrayList()
private val watchRestartQueue: MutableList<Pair<UpdateDeviceId<*>, () -> Unit>> =
CopyOnWriteArrayList()
private val updatingDevicesStatus: MutableMap<UpdateDeviceId<*>, UpdateStatusEvent<*>> =
ConcurrentHashMap()
private val listeners: MutableList<FirmwareUpdateListener> = CopyOnWriteArrayList()
private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob())
private var clearJob: Deferred<Unit>? = null
private var serialRebootHandler: SerialRebootHandler = SerialRebootHandler(watchRestartQueue, server, this)
fun addListener(channel: FirmwareUpdateListener) {
listeners.add(channel)
}
fun removeListener(channel: FirmwareUpdateListener) {
listeners.removeIf { channel == it }
}
init {
server.addTrackerStatusListener(this)
server.provisioningHandler.addListener(this)
server.serialHandler.addListener(serialRebootHandler)
this.updateTickTimer.scheduleAtFixedRate(0, 1000) {
checkUpdateTimeout()
}
}
private suspend fun startOtaUpdate(
part: DownloadedFirmwarePart,
deviceId: UpdateDeviceId<Int>,
): Unit = suspendCancellableCoroutine { c ->
val udpDevice: UDPDevice? =
(server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
if (udpDevice == null) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
),
)
return@suspendCancellableCoroutine
}
val task = OTAUpdateTask(
part.firmware,
deviceId,
udpDevice.ipAddress,
::onStatusChange,
)
c.invokeOnCancellation {
task.cancel()
}
task.run()
}
private fun startSerialUpdate(
firmwares: Array<DownloadedFirmwarePart>,
deviceId: UpdateDeviceId<String>,
needManualReboot: Boolean,
ssid: String,
password: String,
) {
// Can't use .toList() on Android
val serialPort = this.server.serialHandler.knownPorts.collect(Collectors.toList())
.find { port -> deviceId.id == port.portLocation }
if (serialPort == null) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND,
),
)
return
}
val flashingHandler = this.server.serialFlashingHandler
if (flashingHandler == null) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD,
),
)
return
}
try {
val flasher = Flasher(flashingHandler)
for (part in firmwares) {
if (part.offset == null) {
error("Offset is empty")
}
flasher.addBin(part.firmware, part.offset.toInt())
}
flasher.addProgressListener(object : FlashingProgressListener {
override fun progress(progress: Float) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.UPLOADING,
(progress * 100).toInt(),
),
)
}
})
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.SYNCING_WITH_MCU,
),
)
flasher.flash(serialPort)
if (needManualReboot) {
if (watchRestartQueue.find { it.first == deviceId } != null) {
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
}
onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.NEED_MANUAL_REBOOT))
server.serialHandler.openSerial(deviceId.id, false)
watchRestartQueue.add(
Pair(deviceId) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.REBOOTING,
),
)
server.provisioningHandler.start(
ssid,
password,
serialPort.portLocation,
)
},
)
} else {
onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.REBOOTING))
server.provisioningHandler.start(ssid, password, serialPort.portLocation)
}
} catch (e: Exception) {
LogManager.severe("[FirmwareUpdateHandler] Upload failed", e)
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
),
)
}
}
fun queueFirmwareUpdate(
request: FirmwareUpdateRequestT,
deviceId: UpdateDeviceId<*>,
) = mainScope.launch {
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
clearJob?.await()
if (method == FirmwareUpdateMethod.OTA) {
if (watchRestartQueue.find { it.first == deviceId } != null) {
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
}
val udpDevice: UDPDevice? =
(server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice?
if (udpDevice === null) {
error("invalid state - device does not exist")
}
if (udpDevice.protocolVersion <= 20) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.NEED_MANUAL_REBOOT,
),
)
watchRestartQueue.add(
Pair(deviceId) {
mainScope.launch {
startFirmwareUpdateJob(
request,
deviceId,
)
}
},
)
} else {
startFirmwareUpdateJob(
request,
deviceId,
)
}
} else {
if (updatingDevicesStatus[deviceId] != null) {
LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping")
return@launch
}
startFirmwareUpdateJob(
request,
deviceId,
)
}
}
fun cancelUpdates() {
val oldClearJob = clearJob
clearJob = mainScope.async {
oldClearJob?.await()
watchRestartQueue.clear()
runningJobs.forEach { it.cancelAndJoin() }
runningJobs.clear()
LogManager.info("[FirmwareUpdateHandler] Update jobs canceled")
}
}
private fun getFirmwareParts(request: FirmwareUpdateRequestT): ArrayList<FirmwarePartT> {
val parts = ArrayList<FirmwarePartT>()
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
when (method) {
FirmwareUpdateMethod.OTA -> {
val updateReq = request.method.asOTAFirmwareUpdate()
parts.add(updateReq.firmwarePart)
}
FirmwareUpdateMethod.SERIAL -> {
val updateReq = request.method.asSerialFirmwareUpdate()
parts.addAll(updateReq.firmwarePart)
}
FirmwareUpdateMethod.NONE -> error("Method should not be NONE")
}
return parts
}
private suspend fun startFirmwareUpdateJob(
request: FirmwareUpdateRequestT,
deviceId: UpdateDeviceId<*>,
) = coroutineScope {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.DOWNLOADING,
),
)
try {
val toDownloadParts = getFirmwareParts(request)
val firmwareParts = try {
withTimeoutOrNull(30_000) {
toDownloadParts.map {
val firmware = downloadFirmware(it.url, it.digest)
DownloadedFirmwarePart(
firmware,
it.offset,
)
}.toTypedArray()
}
} catch (e: Exception) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED,
),
)
LogManager.severe("[FirmwareUpdateHandler] Unable to download firmware", e)
return@coroutineScope
}
val job = launch {
withTimeout(2 * 60 * 1000) {
if (firmwareParts.isNullOrEmpty()) {
onStatusChange(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED,
),
)
return@withTimeout
}
val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method")
when (method) {
FirmwareUpdateMethod.NONE -> error("unsupported method")
FirmwareUpdateMethod.OTA -> {
if (deviceId.id !is Int) {
error("invalid state, the device id is not an int")
}
if (firmwareParts.size > 1) {
error("invalid state, ota only use one firmware file")
}
startOtaUpdate(
firmwareParts.first(),
UpdateDeviceId(
FirmwareUpdateMethod.OTA,
deviceId.id,
),
)
}
FirmwareUpdateMethod.SERIAL -> {
val req = request.method.asSerialFirmwareUpdate()
if (deviceId.id !is String) {
error("invalid state, the device id is not a string")
}
startSerialUpdate(
firmwareParts,
UpdateDeviceId(
FirmwareUpdateMethod.SERIAL,
deviceId.id,
),
req.needManualReboot,
req.ssid,
req.password,
)
}
}
}
}
runningJobs.add(job)
} catch (e: Exception) {
onStatusChange(
UpdateStatusEvent(
deviceId,
if (e is TimeoutCancellationException) FirmwareUpdateStatus.ERROR_TIMEOUT else FirmwareUpdateStatus.ERROR_UNKNOWN,
),
)
if (e !is TimeoutCancellationException) {
LogManager.severe("[FirmwareUpdateHandler] Update process timed out", e)
e.printStackTrace()
}
return@coroutineScope
}
}
private fun <T> onStatusChange(event: UpdateStatusEvent<T>) {
this.updatingDevicesStatus[event.deviceId] = event
if (event.status == FirmwareUpdateStatus.DONE || event.status.isError()) {
this.updatingDevicesStatus.remove(event.deviceId)
// we remove the device from the restart queue
val queuedDevice = watchRestartQueue.find { it.first.id == event.deviceId }
if (queuedDevice != null) {
watchRestartQueue.remove(queuedDevice)
if (event.deviceId.type == FirmwareUpdateMethod.SERIAL && server.serialHandler.isConnected) {
server.serialHandler.closeSerial()
}
}
// We make sure to stop the provisioning routine if the tracker is done
// flashing
if (event.deviceId.type == FirmwareUpdateMethod.SERIAL) {
this.server.provisioningHandler.stop()
}
}
listeners.forEach { l -> l.onUpdateStatusChange(event) }
}
private fun checkUpdateTimeout() {
updatingDevicesStatus.forEach { (id, device) ->
// if more than 30s between two events, consider the update as stuck
// We do not timeout on the Downloading step as it has it own timeout
// We do not timeout on the Done step as it is the end of the update process
if (!device.status.isError() &&
!intArrayOf(FirmwareUpdateStatus.DONE.id, FirmwareUpdateStatus.DOWNLOADING.id).contains(device.status.id) &&
System.currentTimeMillis() - device.time > 30 * 1000
) {
onStatusChange(
UpdateStatusEvent(
id,
FirmwareUpdateStatus.ERROR_TIMEOUT,
),
)
}
}
}
// this only works for OTA trackers as the device id
// only exists when the usb connection is created
override fun onTrackerStatusChanged(
tracker: Tracker,
oldStatus: TrackerStatus,
newStatus: TrackerStatus,
) {
val device = tracker.device
if (device !is UDPDevice) return
if (oldStatus == TrackerStatus.DISCONNECTED && newStatus == TrackerStatus.OK) {
val queuedDevice = watchRestartQueue.find { it.first.id == device.id }
if (queuedDevice != null) {
queuedDevice.second() // we start the queued update task
watchRestartQueue.remove(queuedDevice) // then we remove it from the queue
return
}
// We can only filter OTA method here as the device id is only provided when using Wi-Fi
val deviceStatusKey =
updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.OTA && it.id == device.id }
?: return
val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return
// We check for the reconnection of the tracker, once the tracker reconnected we notify the user that the update is completed
if (updateStatus.status == FirmwareUpdateStatus.REBOOTING) {
onStatusChange(
UpdateStatusEvent(
updateStatus.deviceId,
FirmwareUpdateStatus.DONE,
),
)
}
}
}
override fun onProvisioningStatusChange(
status: ProvisioningStatus,
port: SerialPort?,
) {
fun update(s: FirmwareUpdateStatus) {
val deviceStatusKey =
updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.SERIAL && it.id == port?.portLocation }
?: return
val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return
onStatusChange(UpdateStatusEvent(updateStatus.deviceId, s))
}
when (status) {
ProvisioningStatus.PROVISIONING -> update(FirmwareUpdateStatus.PROVISIONING)
ProvisioningStatus.DONE -> update(FirmwareUpdateStatus.DONE)
ProvisioningStatus.CONNECTION_ERROR, ProvisioningStatus.COULD_NOT_FIND_SERVER -> update(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED)
else -> {}
}
}
override fun onSerialDeviceReconnect(deviceHandle: Pair<UpdateDeviceId<*>, () -> Unit>) {
deviceHandle.second()
watchRestartQueue.remove(deviceHandle)
}
}
fun downloadFirmware(url: String, expectedDigest: String): ByteArray {
val outputStream = ByteArrayOutputStream()
val chunk = ByteArray(4096)
var bytesRead: Int
val stream: InputStream = URL(url).openStream()
while (stream.read(chunk).also { bytesRead = it } > 0) {
outputStream.write(chunk, 0, bytesRead)
}
val downloadedData = outputStream.toByteArray()
if (!verifyChecksum(downloadedData, expectedDigest)) {
error("Checksum verification failed for $url")
}
return downloadedData
}
fun verifyChecksum(data: ByteArray, expectedDigest: String): Boolean {
val parts = expectedDigest.split(":", limit = 2)
if (parts.size != 2) {
error("Invalid digest format. Expected 'algorithm:hash' got $expectedDigest")
}
val algorithm = parts[0].uppercase().replace("-", "")
val expectedHash = parts[1].lowercase()
val messageDigest = MessageDigest.getInstance(algorithm)
val actualHash = messageDigest.digest(data).joinToString("") {
"%02x".format(it)
}
return actualHash == expectedHash
}

View File

@@ -1,5 +0,0 @@
package dev.slimevr.firmware
interface FirmwareUpdateListener {
fun onUpdateStatusChange(event: UpdateStatusEvent<*>)
}

View File

@@ -1,14 +0,0 @@
package dev.slimevr.firmware
enum class FirmwareUpdateMethod(val id: Byte) {
NONE(solarxr_protocol.rpc.FirmwareUpdateMethod.NONE),
OTA(solarxr_protocol.rpc.FirmwareUpdateMethod.OTAFirmwareUpdate),
SERIAL(solarxr_protocol.rpc.FirmwareUpdateMethod.SerialFirmwareUpdate),
;
companion object {
fun getById(id: Byte): FirmwareUpdateMethod? = byId[id]
}
}
private val byId = FirmwareUpdateMethod.entries.associateBy { it.id }

View File

@@ -1,29 +0,0 @@
package dev.slimevr.firmware
enum class FirmwareUpdateStatus(val id: Int) {
DOWNLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.DOWNLOADING),
AUTHENTICATING(solarxr_protocol.rpc.FirmwareUpdateStatus.AUTHENTICATING),
UPLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.UPLOADING),
SYNCING_WITH_MCU(solarxr_protocol.rpc.FirmwareUpdateStatus.SYNCING_WITH_MCU),
REBOOTING(solarxr_protocol.rpc.FirmwareUpdateStatus.REBOOTING),
NEED_MANUAL_REBOOT(solarxr_protocol.rpc.FirmwareUpdateStatus.NEED_MANUAL_REBOOT),
PROVISIONING(solarxr_protocol.rpc.FirmwareUpdateStatus.PROVISIONING),
DONE(solarxr_protocol.rpc.FirmwareUpdateStatus.DONE),
ERROR_DEVICE_NOT_FOUND(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND),
ERROR_TIMEOUT(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_TIMEOUT),
ERROR_DOWNLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED),
ERROR_AUTHENTICATION_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED),
ERROR_UPLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UPLOAD_FAILED),
ERROR_PROVISIONING_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED),
ERROR_UNSUPPORTED_METHOD(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD),
ERROR_UNKNOWN(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNKNOWN),
;
fun isError(): Boolean = id in ERROR_DEVICE_NOT_FOUND.id..ERROR_UNKNOWN.id
companion object {
fun getById(id: Int): FirmwareUpdateStatus? = byId[id]
}
}
private val byId = FirmwareUpdateStatus.entries.associateBy { it.id }

View File

@@ -1,205 +0,0 @@
package dev.slimevr.firmware
import io.eiren.util.logging.LogManager
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.IOException
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.util.*
import java.util.function.Consumer
import kotlin.math.min
class OTAUpdateTask(
private val firmware: ByteArray,
private val deviceId: UpdateDeviceId<Int>,
private val deviceIp: InetAddress,
private val statusCallback: Consumer<UpdateStatusEvent<Int>>,
) {
private val receiveBuffer: ByteArray = ByteArray(38)
var socketServer: ServerSocket? = null
var uploadSocket: Socket? = null
var authSocket: DatagramSocket? = null
var canceled: Boolean = false
@Throws(NoSuchAlgorithmException::class)
private fun bytesToMd5(bytes: ByteArray): String {
val md5 = MessageDigest.getInstance("MD5")
md5.update(bytes)
val digest = md5.digest()
val md5str = StringBuilder()
for (b in digest) {
md5str.append(String.format("%02x", b))
}
return md5str.toString()
}
private fun authenticate(localPort: Int): Boolean {
try {
DatagramSocket().use { socket ->
authSocket = socket
statusCallback.accept(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.AUTHENTICATING))
LogManager.info("[OTAUpdate] Sending OTA invitation to: $deviceIp")
val fileMd5 = bytesToMd5(firmware)
val message = "$FLASH $localPort ${firmware.size} $fileMd5\n"
socket.send(DatagramPacket(message.toByteArray(), message.length, deviceIp, PORT))
socket.soTimeout = 10000
val authPacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
socket.receive(authPacket)
val data = String(authPacket.data, 0, authPacket.length)
// if we received OK directly from the MCU, we do not need to authenticate
if (data == "OK") return true
val args = data.split(" ")
// The expected auth payload should look like "AUTH AUTH_TOKEN"
// if we have less than those two args it means that we are in an invalid state
if (args.size != 2 || args[0] != "AUTH") return false
LogManager.info("[OTAUpdate] Authenticating...")
val authToken = args[1]
val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray())
val hashedPassword = bytesToMd5(PASSWORD.toByteArray())
val resultText = "$hashedPassword:$authToken:$signature"
val payload = bytesToMd5(resultText.toByteArray())
val authMessage = "$AUTH $signature $payload\n"
socket.soTimeout = 10000
socket.send(
DatagramPacket(
authMessage.toByteArray(),
authMessage.length,
deviceIp,
PORT,
),
)
val authResponsePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
socket.receive(authResponsePacket)
val authResponse = String(authResponsePacket.data, 0, authResponsePacket.length)
return authResponse == "OK"
}
} catch (e: Exception) {
LogManager.severe("OTA Authentication exception", e)
return false
}
}
private fun upload(serverSocket: ServerSocket): Boolean {
var connection: Socket? = null
try {
LogManager.info("[OTAUpdate] Starting on: ${serverSocket.localPort}")
LogManager.info("[OTAUpdate] Waiting for device...")
connection = serverSocket.accept()
this.uploadSocket = connection
connection.setSoTimeout(1000)
val dos = DataOutputStream(connection.getOutputStream())
val dis = DataInputStream(connection.getInputStream())
LogManager.info("[OTAUpdate] Upload size: ${firmware.size} bytes")
var offset = 0
val chunkSize = 2048
while (offset != firmware.size && !canceled) {
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.UPLOADING,
((offset.toDouble() / firmware.size) * 100).toInt(),
),
)
val chunkLen = min(chunkSize, (firmware.size - offset))
dos.write(firmware, offset, chunkLen)
dos.flush()
offset += chunkLen
// Those skipped bytes are the size written to the MCU. We do not really need that information,
// so we simply skip it.
// The reason those bytes are skipped here is to not have to skip all of them when checking
// for the OK response. Saving time
val bytesSkipped = dis.skipBytes(4)
// Replicate behaviour of .skipNBytes()
if (bytesSkipped != 4) {
throw IOException("Unexpected number of bytes skipped: $bytesSkipped")
}
}
if (canceled) return false
LogManager.info("[OTAUpdate] Waiting for result...")
// We set the timeout of the connection bigger as it can take some time for the MCU
// to confirm that everything is ok
connection.setSoTimeout(10000)
val responseBytes = dis.readBytes()
val response = String(responseBytes)
return response.contains("OK")
} catch (e: Exception) {
LogManager.severe("Unable to upload the firmware using ota", e)
return false
} finally {
connection?.close()
}
}
fun run() {
ServerSocket(0).use { serverSocket ->
socketServer = serverSocket
if (!authenticate(serverSocket.localPort)) {
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED,
),
)
return
}
if (!upload(serverSocket)) {
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.ERROR_UPLOAD_FAILED,
),
)
return
}
statusCallback.accept(
UpdateStatusEvent(
deviceId,
FirmwareUpdateStatus.REBOOTING,
),
)
}
}
fun cancel() {
canceled = true
socketServer?.close()
authSocket?.close()
uploadSocket?.close()
}
companion object {
private const val FLASH = 0
private const val PORT = 8266
private const val PASSWORD = "SlimeVR-OTA"
private const val AUTH = 200
}
}

View File

@@ -1,5 +0,0 @@
package dev.slimevr.firmware
import dev.llelievr.espflashkotlin.FlasherSerialInterface
interface SerialFlashingHandler : FlasherSerialInterface

View File

@@ -1,66 +0,0 @@
package dev.slimevr.firmware
import dev.slimevr.VRServer
import dev.slimevr.serial.SerialListener
import dev.slimevr.serial.SerialPort
import java.util.concurrent.CopyOnWriteArrayList
interface SerialRebootListener {
fun onSerialDeviceReconnect(deviceHandle: Pair<UpdateDeviceId<*>, () -> Unit>)
}
/**
* This class watch for a serial device to disconnect then reconnect.
* This is used to watch the user progress through the firmware update process
*/
class SerialRebootHandler(
private val watchRestartQueue: MutableList<Pair<UpdateDeviceId<*>, () -> Unit>>,
private val server: VRServer,
// Could be moved to a list of listeners later
private val serialRebootListener: SerialRebootListener,
) : SerialListener {
private var currentPort: SerialPort? = null
private val disconnectedDevices: MutableList<SerialPort> = CopyOnWriteArrayList()
override fun onSerialConnected(port: SerialPort) {
currentPort = port
}
override fun onSerialDisconnected() {
currentPort = null
}
override fun onSerialLog(str: String, ignored: Boolean) {
if (str.contains("starting up...")) {
val foundPort = watchRestartQueue.find { it.first.id == currentPort?.portLocation }
if (foundPort != null) {
disconnectedDevices.remove(currentPort)
serialRebootListener.onSerialDeviceReconnect(foundPort)
// once the restart detected we close the connection
if (server.serialHandler.isConnected) {
server.serialHandler.closeSerial()
}
}
}
}
override fun onNewSerialDevice(port: SerialPort) {
val foundPort = watchRestartQueue.find { it.first.id == port.portLocation }
if (foundPort != null && disconnectedDevices.contains(port)) {
disconnectedDevices.remove(port)
serialRebootListener.onSerialDeviceReconnect(foundPort)
// once the restart detected we close the connection
if (server.serialHandler.isConnected) {
server.serialHandler.closeSerial()
}
}
}
override fun onSerialDeviceDeleted(port: SerialPort) {
val foundPort = watchRestartQueue.find { it.first.id == port.portLocation }
if (foundPort != null) {
disconnectedDevices.add(port)
}
}
}

View File

@@ -1,24 +0,0 @@
package dev.slimevr.firmware
data class UpdateDeviceId<T>(
val type: FirmwareUpdateMethod,
val id: T,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UpdateDeviceId<*>
if (type != other.type) return false
if (id != other.id) return false
return true
}
override fun hashCode(): Int {
var result = type.hashCode()
result = 31 * result + (id?.hashCode() ?: 0)
return result
}
}

View File

@@ -1,8 +0,0 @@
package dev.slimevr.firmware
data class UpdateStatusEvent<T>(
val deviceId: UpdateDeviceId<T>,
val status: FirmwareUpdateStatus,
val progress: Int = 0,
val time: Long = System.currentTimeMillis(),
)

View File

@@ -1,217 +0,0 @@
package dev.slimevr.games.vrchat
import dev.slimevr.VRServer
import dev.slimevr.tracking.processor.config.SkeletonConfigToggles
import dev.slimevr.tracking.trackers.TrackerPosition
import dev.slimevr.tracking.trackers.TrackerUtils
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.*
enum class VRCTrackerModel(val value: Int, val id: Int) {
UNKNOWN(-1, solarxr_protocol.rpc.VRCTrackerModel.UNKNOWN),
SPHERE(0, solarxr_protocol.rpc.VRCTrackerModel.SPHERE),
SYSTEM(1, solarxr_protocol.rpc.VRCTrackerModel.SYSTEM),
BOX(2, solarxr_protocol.rpc.VRCTrackerModel.BOX),
AXIS(3, solarxr_protocol.rpc.VRCTrackerModel.AXIS),
;
companion object {
private val byValue = VRCTrackerModel.entries.associateBy { it.value }
fun getByValue(value: Int): VRCTrackerModel? = byValue[value]
}
}
enum class VRCSpineMode(val value: Int, val id: Int) {
UNKNOWN(-1, solarxr_protocol.rpc.VRCSpineMode.UNKNOWN),
LOCK_HIP(0, solarxr_protocol.rpc.VRCSpineMode.LOCK_HIP),
LOCK_HEAD(1, solarxr_protocol.rpc.VRCSpineMode.LOCK_HEAD),
LOCK_BOTH(2, solarxr_protocol.rpc.VRCSpineMode.LOCK_BOTH),
;
companion object {
private val byValue = VRCSpineMode.entries.associateBy { it.value }
fun getByValue(value: Int): VRCSpineMode? = byValue[value]
}
}
enum class VRCAvatarMeasurementType(val value: Int, val id: Int) {
UNKNOWN(-1, solarxr_protocol.rpc.VRCAvatarMeasurementType.UNKNOWN),
ARM_SPAN(0, solarxr_protocol.rpc.VRCAvatarMeasurementType.ARM_SPAN),
HEIGHT(1, solarxr_protocol.rpc.VRCAvatarMeasurementType.HEIGHT),
;
companion object {
private val byValue = VRCAvatarMeasurementType.entries.associateBy { it.value }
fun getByValue(value: Int): VRCAvatarMeasurementType? = byValue[value]
}
}
data class VRCConfigValues(
val legacyMode: Boolean,
val shoulderTrackingDisabled: Boolean,
val shoulderWidthCompensation: Boolean,
val userHeight: Double,
val calibrationRange: Double,
val calibrationVisuals: Boolean,
val trackerModel: VRCTrackerModel,
val spineMode: VRCSpineMode,
val avatarMeasurementType: VRCAvatarMeasurementType,
)
data class VRCConfigRecommendedValues(
val legacyMode: Boolean,
val shoulderTrackingDisabled: Boolean,
val shoulderWidthCompensation: Boolean,
val userHeight: Double,
val calibrationRange: Double,
val calibrationVisuals: Boolean,
val trackerModel: VRCTrackerModel,
val spineMode: Array<VRCSpineMode>,
val avatarMeasurementType: VRCAvatarMeasurementType,
)
data class VRCConfigValidity(
val legacyModeOk: Boolean,
val shoulderTrackingOk: Boolean,
val shoulderWidthCompensationOk: Boolean,
val userHeightOk: Boolean,
val calibrationRangeOk: Boolean,
val calibrationVisualsOk: Boolean,
val trackerModelOk: Boolean,
val spineModeOk: Boolean,
val avatarMeasurementTypeOk: Boolean,
)
abstract class VRCConfigHandler {
abstract val isSupported: Boolean
abstract fun initHandler(onChange: (config: VRCConfigValues) -> Unit)
}
class VRCConfigHandlerStub : VRCConfigHandler() {
override val isSupported: Boolean
get() = false
override fun initHandler(onChange: (config: VRCConfigValues) -> Unit) {}
}
interface VRCConfigListener {
fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues, muted: List<String>)
}
class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) {
private val listeners: MutableList<VRCConfigListener> = CopyOnWriteArrayList()
var currentValues: VRCConfigValues? = null
var currentValidity: VRCConfigValidity? = null
val isSupported: Boolean
get() = handler.isSupported
init {
handler.initHandler(::onChange)
}
fun toggleMuteWarning(key: String) {
val keys = VRCConfigValidity::class.java.declaredFields.asSequence().map { p -> p.name }
if (!keys.contains(key)) return
if (!server.configManager.vrConfig.vrcConfig.mutedWarnings.contains(key)) {
server.configManager.vrConfig.vrcConfig.mutedWarnings.add(key)
} else {
server.configManager.vrConfig.vrcConfig.mutedWarnings.remove(key)
}
server.configManager.saveConfig()
val recommended = recommendedValues()
val validity = currentValidity ?: return
val values = currentValues ?: return
listeners.forEach {
it.onChange(
validity,
values,
recommended,
server.configManager.vrConfig.vrcConfig.mutedWarnings,
)
}
}
/**
* shoulderTrackingDisabled should be true if:
* The user isn't tracking their whole arms from their controllers:
* forceArmsFromHMD is enabled || the user doesn't have hand trackers with position || the user doesn't have lower arms trackers || the user doesn't have upper arm trackers
* And the user isn't tracking their arms from their HMD or doesn't have both shoulders:
* (forceArmsFromHMD is disabled && user has hand trackers with position) || user is missing a shoulder tracker
*/
fun recommendedValues(): VRCConfigRecommendedValues {
val forceArmsFromHMD = server.humanPoseManager.getToggle(SkeletonConfigToggles.FORCE_ARMS_FROM_HMD)
val hasLeftHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_HAND)?.hasPosition ?: false
val hasRightHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_HAND)?.hasPosition ?: false
val isMissingAnArmTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_LOWER_ARM) == null ||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_LOWER_ARM) == null ||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_UPPER_ARM) == null ||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_UPPER_ARM) == null
val isMissingAShoulderTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_SHOULDER) == null ||
TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_SHOULDER) == null
return VRCConfigRecommendedValues(
legacyMode = false,
shoulderTrackingDisabled =
((forceArmsFromHMD || !hasLeftHandWithPosition || !hasRightHandWithPosition) || isMissingAnArmTracker) && // Not tracking shoulders from hands
((!forceArmsFromHMD && hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), // Not tracking shoulders from HMD
userHeight = server.humanPoseManager.realUserHeight.toDouble(),
calibrationRange = 0.2,
trackerModel = VRCTrackerModel.AXIS,
spineMode = arrayOf(VRCSpineMode.LOCK_HIP, VRCSpineMode.LOCK_HEAD),
calibrationVisuals = true,
avatarMeasurementType = VRCAvatarMeasurementType.HEIGHT,
shoulderWidthCompensation = true,
)
}
fun addListener(listener: VRCConfigListener) {
listeners.add(listener)
val values = currentValues ?: return
val recommended = recommendedValues()
val validity = checkValidity(values, recommended)
listener.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings)
}
fun removeListener(listener: VRCConfigListener) {
listeners.removeIf { l -> l === listener }
}
fun checkValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity = VRCConfigValidity(
legacyModeOk = values.legacyMode == recommended.legacyMode,
shoulderTrackingOk = values.shoulderTrackingDisabled == recommended.shoulderTrackingDisabled,
spineModeOk = recommended.spineMode.contains(values.spineMode),
trackerModelOk = values.trackerModel == recommended.trackerModel,
calibrationRangeOk = abs(values.calibrationRange - recommended.calibrationRange) < 0.1,
userHeightOk = abs(server.humanPoseManager.realUserHeight - values.userHeight) < 0.1,
calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals,
avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType,
shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation,
)
fun forceUpdate() {
val values = currentValues
if (values != null) {
this.onChange(values)
}
}
fun onChange(values: VRCConfigValues) {
val recommended = recommendedValues()
val validity = checkValidity(values, recommended)
currentValidity = validity
currentValues = values
listeners.forEach {
it.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings)
}
}
}

View File

@@ -1,28 +0,0 @@
package dev.slimevr.guards
import java.util.Timer
import java.util.TimerTask
import kotlin.concurrent.schedule
class ServerGuards {
var canDoMounting: Boolean = false
var canDoYawReset: Boolean = false
var canDoUserHeightCalibration: Boolean = false
private val timer = Timer()
private var mountingTimeoutTask: TimerTask? = null
fun onFullReset() {
canDoMounting = true
canDoYawReset = true
mountingTimeoutTask?.cancel()
mountingTimeoutTask = timer.schedule(MOUNTING_RESET_TIMEOUT) {
canDoMounting = false
}
}
companion object {
const val MOUNTING_RESET_TIMEOUT = 2 * 60 * 1000L
}
}

View File

@@ -1,67 +0,0 @@
package dev.slimevr.math
import com.jme3.math.FastMath
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Vector3
import kotlin.math.*
/**
* An angle between [-PI, PI).
*/
@JvmInline
value class Angle(private val rad: Float) {
fun toRad() = rad
fun toDeg() = rad * FastMath.RAD_TO_DEG
operator fun unaryPlus() = this
operator fun unaryMinus() = Angle(normalize(-rad))
operator fun plus(other: Angle) = Angle(normalize(rad + other.rad))
operator fun minus(other: Angle) = Angle(normalize(rad - other.rad))
operator fun times(scale: Float) = Angle(normalize(rad * scale))
operator fun div(scale: Float) = Angle(normalize(rad / scale))
operator fun compareTo(other: Angle) = rad.compareTo(other.rad)
override fun toString() = "${toDeg()} deg"
companion object {
val ZERO = Angle(0.0f)
fun ofRad(rad: Float) = Angle(normalize(rad))
fun ofDeg(deg: Float) = Angle(normalize(deg * FastMath.DEG_TO_RAD))
// Angle between two vectors
fun absBetween(a: Vector3, b: Vector3) = Angle(normalize(a.angleTo(b)))
// Angle between two rotations in rotation space
fun absBetween(a: Quaternion, b: Quaternion) = Angle(normalize(a.angleToR(b)))
/**
* Normalizes an angle to [-PI, PI)
*/
private fun normalize(rad: Float): Float {
// Normalize to [0, 2*PI)
val r =
if (rad < 0.0f || rad >= FastMath.TWO_PI) {
rad - floor(rad * FastMath.INV_TWO_PI) * FastMath.TWO_PI
} else {
rad
}
// Normalize to [-PI, PI)
return if (r > FastMath.PI) {
r - FastMath.TWO_PI
} else {
r
}
}
}
}

View File

@@ -1,36 +0,0 @@
package dev.slimevr.math
import kotlin.math.*
/**
* Averages angles by summing vectors.
*
* See https://www.themathdoctors.org/averaging-angles/
*/
class AngleAverage {
private var sumX = 0.0f
private var sumY = 0.0f
/**
* Adds another angle to the average.
*/
fun add(angle: Angle, weight: Float = 1.0f) {
sumX += cos(angle.toRad()) * weight
sumY += sin(angle.toRad()) * weight
}
/**
* Gets the average angle.
*/
fun toAngle(): Angle = if (isEmpty()) {
Angle.ZERO
} else {
Angle.ofRad(atan2(sumY, sumX))
}
/**
* Whether there are any angles to average.
*/
fun isEmpty() = sumX == 0.0f && sumY == 0.0f
}

View File

@@ -1,14 +0,0 @@
package dev.slimevr.math
import kotlin.math.*
class AngleErrors {
private var sumSqrErrors = 0.0f
fun add(error: Angle) {
sumSqrErrors += error.toRad() * error.toRad()
}
fun toL2Norm() = Angle.ofRad(sqrt(sumSqrErrors))
}

View File

@@ -1,29 +0,0 @@
package dev.slimevr.osc;
import com.illposed.osc.transport.OSCPortIn;
import com.illposed.osc.transport.OSCPortOut;
import java.net.InetAddress;
public interface OSCHandler {
public void refreshSettings(boolean refreshRouterSettings);
public void updateOscReceiver(int portIn, String[] args);
public void updateOscSender(int portOut, String address);
public void update();
public OSCPortOut getOscSender();
public int getPortOut();
public InetAddress getAddress();
public OSCPortIn getOscReceiver();
public int getPortIn();
}

View File

@@ -1,179 +0,0 @@
package dev.slimevr.osc;
import com.illposed.osc.*;
import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector;
import com.illposed.osc.transport.OSCPortIn;
import com.illposed.osc.transport.OSCPortOut;
import dev.slimevr.config.OSCConfig;
import io.eiren.util.collections.FastList;
import io.eiren.util.logging.LogManager;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
public class OSCRouter {
private OSCPortIn oscReceiver;
private OSCPortOut oscSender;
private final OSCConfig config;
private final FastList<OSCHandler> oscHandlers;
private int lastPortIn;
private int lastPortOut;
private InetAddress lastAddress;
private long timeAtLastError;
public OSCRouter(
OSCConfig oscConfig,
FastList<OSCHandler> oscHandlers
) {
this.config = oscConfig;
this.oscHandlers = oscHandlers;
refreshSettings(false);
}
public void refreshSettings(boolean refreshHandlersSettings) {
if (refreshHandlersSettings) {
for (OSCHandler oscHandler : oscHandlers) {
oscHandler.refreshSettings(false);
}
}
// Stops listening and closes OSC port
boolean wasListening = oscReceiver != null && oscReceiver.isListening();
if (wasListening) {
oscReceiver.stopListening();
}
oscReceiver = null;
boolean wasConnected = oscSender != null && oscSender.isConnected();
if (wasConnected) {
try {
oscSender.close();
} catch (IOException e) {
LogManager.severe("[OSCRouter] Error closing the OSC sender: " + e);
}
}
oscSender = null;
if (config.getEnabled()) {
// Instantiates the OSC receiver
int portIn = config.getPortIn();
// Check if another OSC receiver with same port exists
for (OSCHandler oscHandler : oscHandlers) {
if (oscHandler.getPortIn() == portIn) {
if (oscHandler.getOscReceiver().isListening()) {
oscReceiver = oscHandler.getOscReceiver();
LogManager.info("[OSCRouter] Listening to port " + portIn);
}
}
}
// Else, create our own OSC receiver
if (oscReceiver == null) {
try {
oscReceiver = new OSCPortIn(portIn);
if (lastPortIn != portIn || !wasListening) {
LogManager.info("[OSCRouter] Listening to port " + portIn);
}
lastPortIn = portIn;
} catch (IOException e) {
LogManager
.severe(
"[OSCRouter] Error listening to the port "
+ config.getPortIn()
+ ": "
+ e
);
}
}
// Instantiate the OSC sender
int portOut = config.getPortOut();
InetAddress address;
try {
address = InetAddress.getByName(config.getAddress());
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
// Check if another OSC sender with same port and address exists
for (OSCHandler oscHandler : oscHandlers) {
if (oscHandler.getPortOut() == portOut && oscHandler.getAddress() == address) {
if (oscHandler.getOscSender().isConnected()) {
oscSender = oscHandler.getOscSender();
LogManager
.info(
"[OSCRouter] Sending to port "
+ portOut
+ " at address "
+ address.toString()
);
}
}
}
// Else, create our own OSC sender
if (oscSender == null) {
try {
oscSender = new OSCPortOut(new InetSocketAddress(address, portOut));
if ((lastPortOut != portOut && lastAddress != address) || !wasConnected) {
LogManager
.info(
"[OSCRouter] Sending to port "
+ portOut
+ " at address "
+ address.toString()
);
}
lastPortOut = portOut;
lastAddress = address;
oscSender.connect();
} catch (IOException e) {
LogManager
.severe(
"[OSCRouter] Error connecting to port "
+ config.getPortOut()
+ " at the address "
+ config.getAddress()
+ ": "
+ e
);
}
}
// Starts listening to messages
if (oscReceiver != null) {
OSCMessageListener listener = this::handleReceivedMessage;
// Listens for any message ("//" is a wildcard)
MessageSelector selector = new OSCPatternAddressMessageSelector("//");
oscReceiver.getDispatcher().addListener(selector, listener);
if (!oscReceiver.isListening())
oscReceiver.startListening();
}
}
}
void handleReceivedMessage(OSCMessageEvent event) {
if (oscSender != null && oscSender.isConnected()) {
OSCMessage oscMessage = new OSCMessage(
event.getMessage().getAddress(),
event.getMessage().getArguments()
);
try {
oscSender.send(oscMessage);
} catch (IOException | OSCSerializeException e) {
// Avoid spamming AsynchronousCloseException too many
// times per second
if (System.currentTimeMillis() - timeAtLastError > 100) {
timeAtLastError = System.currentTimeMillis();
LogManager
.warning(
"[OSCRouter] Error sending OSC packet: "
+ e
);
}
}
}
}
}

View File

@@ -1,332 +0,0 @@
package dev.slimevr.osc
import com.jme3.math.FastMath
import dev.slimevr.tracking.processor.TransformNode
import io.github.axisangles.ktmath.EulerAngles
import io.github.axisangles.ktmath.EulerOrder
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Vector3
/**
* TODO make this class use Bone.kt
*/
class UnityArmature(localRot: Boolean) {
// Head
private val headNode = TransformNode(localRotation = localRot)
private val neckTailNode = TransformNode(localRotation = localRot)
private val neckHeadNode = TransformNode(localRotation = localRot)
// Spine
private val upperChestNode = TransformNode(localRotation = localRot)
private val chestNode = TransformNode(localRotation = localRot)
private val spineTailNode = TransformNode(localRotation = localRot)
private val spineHeadNode = TransformNode(localRotation = localRot)
private val hipsNode = TransformNode(localRotation = localRot)
private val leftHipNode = TransformNode(localRotation = localRot)
private val rightHipNode = TransformNode(localRotation = localRot)
// Legs
private val leftKneeNode = TransformNode(localRotation = localRot)
private val leftAnkleNode = TransformNode(localRotation = localRot)
private val leftFootNode = TransformNode(localRotation = localRot)
private val rightKneeNode = TransformNode(localRotation = localRot)
private val rightAnkleNode = TransformNode(localRotation = localRot)
private val rightFootNode = TransformNode(localRotation = localRot)
// Arms
private val leftShoulderHeadNode = TransformNode(localRotation = localRot)
private val rightShoulderHeadNode = TransformNode(localRotation = localRot)
private val leftShoulderTailNode = TransformNode(localRotation = localRot)
private val rightShoulderTailNode = TransformNode(localRotation = localRot)
private val leftElbowNode = TransformNode(localRotation = localRot)
private val rightElbowNode = TransformNode(localRotation = localRot)
private val leftWristNode = TransformNode(localRotation = localRot)
private val rightWristNode = TransformNode(localRotation = localRot)
private val leftHandNode = TransformNode(localRotation = !localRot)
private val rightHandNode = TransformNode(localRotation = !localRot)
// Fingers
val leftThumbProximalHeadNode = TransformNode(localRotation = localRot)
val leftThumbProximalTailNode = TransformNode(localRotation = localRot)
val leftThumbIntermediateNode = TransformNode(localRotation = localRot)
val leftThumbDistalNode = TransformNode(localRotation = localRot)
val leftIndexProximalHeadNode = TransformNode(localRotation = localRot)
val leftIndexProximalTailNode = TransformNode(localRotation = localRot)
val leftIndexIntermediateNode = TransformNode(localRotation = localRot)
val leftIndexDistalNode = TransformNode(localRotation = localRot)
val leftMiddleProximalHeadNode = TransformNode(localRotation = localRot)
val leftMiddleProximalTailNode = TransformNode(localRotation = localRot)
val leftMiddleIntermediateNode = TransformNode(localRotation = localRot)
val leftMiddleDistalNode = TransformNode(localRotation = localRot)
val leftRingProximalHeadNode = TransformNode(localRotation = localRot)
val leftRingProximalTailNode = TransformNode(localRotation = localRot)
val leftRingIntermediateNode = TransformNode(localRotation = localRot)
val leftRingDistalNode = TransformNode(localRotation = localRot)
val leftLittleProximalHeadNode = TransformNode(localRotation = localRot)
val leftLittleProximalTailNode = TransformNode(localRotation = localRot)
val leftLittleIntermediateNode = TransformNode(localRotation = localRot)
val leftLittleDistalNode = TransformNode(localRotation = localRot)
val rightThumbProximalHeadNode = TransformNode(localRotation = localRot)
val rightThumbProximalTailNode = TransformNode(localRotation = localRot)
val rightThumbIntermediateNode = TransformNode(localRotation = localRot)
val rightThumbDistalNode = TransformNode(localRotation = localRot)
val rightIndexProximalHeadNode = TransformNode(localRotation = localRot)
val rightIndexProximalTailNode = TransformNode(localRotation = localRot)
val rightIndexIntermediateNode = TransformNode(localRotation = localRot)
val rightIndexDistalNode = TransformNode(localRotation = localRot)
val rightMiddleProximalHeadNode = TransformNode(localRotation = localRot)
val rightMiddleProximalTailNode = TransformNode(localRotation = localRot)
val rightMiddleIntermediateNode = TransformNode(localRotation = localRot)
val rightMiddleDistalNode = TransformNode(localRotation = localRot)
val rightRingProximalHeadNode = TransformNode(localRotation = localRot)
val rightRingProximalTailNode = TransformNode(localRotation = localRot)
val rightRingIntermediateNode = TransformNode(localRotation = localRot)
val rightRingDistalNode = TransformNode(localRotation = localRot)
val rightLittleProximalHeadNode = TransformNode(localRotation = localRot)
val rightLittleProximalTailNode = TransformNode(localRotation = localRot)
val rightLittleIntermediateNode = TransformNode(localRotation = localRot)
val rightLittleDistalNode = TransformNode(localRotation = localRot)
private var rootPosition = Vector3.NULL
private var rootRotation = Quaternion.IDENTITY
init {
// Attach nodes
// Spine
hipsNode.attachChild(spineHeadNode)
spineHeadNode.attachChild(spineTailNode)
spineTailNode.attachChild(chestNode)
chestNode.attachChild(upperChestNode)
upperChestNode.attachChild(neckHeadNode)
neckHeadNode.attachChild(neckTailNode)
neckTailNode.attachChild(headNode)
// Legs
hipsNode.attachChild(leftHipNode)
hipsNode.attachChild(rightHipNode)
leftHipNode.attachChild(leftKneeNode)
rightHipNode.attachChild(rightKneeNode)
leftKneeNode.attachChild(leftAnkleNode)
rightKneeNode.attachChild(rightAnkleNode)
leftAnkleNode.attachChild(leftFootNode)
rightAnkleNode.attachChild(rightFootNode)
// Arms
upperChestNode.attachChild(leftShoulderHeadNode)
upperChestNode.attachChild(rightShoulderHeadNode)
leftShoulderHeadNode.attachChild(leftShoulderTailNode)
rightShoulderHeadNode.attachChild(rightShoulderTailNode)
leftShoulderTailNode.attachChild(leftElbowNode)
rightShoulderTailNode.attachChild(rightElbowNode)
leftElbowNode.attachChild(leftWristNode)
rightElbowNode.attachChild(rightWristNode)
leftWristNode.attachChild(leftHandNode)
rightWristNode.attachChild(rightHandNode)
// Fingers
leftHandNode.attachChild(leftThumbProximalHeadNode)
leftThumbProximalHeadNode.attachChild(leftThumbProximalTailNode)
leftThumbProximalTailNode.attachChild(leftThumbIntermediateNode)
leftThumbIntermediateNode.attachChild(leftThumbDistalNode)
leftHandNode.attachChild(leftIndexProximalHeadNode)
leftIndexProximalHeadNode.attachChild(leftIndexProximalTailNode)
leftIndexProximalTailNode.attachChild(leftIndexIntermediateNode)
leftIndexIntermediateNode.attachChild(leftIndexDistalNode)
leftHandNode.attachChild(leftMiddleProximalHeadNode)
leftMiddleProximalHeadNode.attachChild(leftMiddleProximalTailNode)
leftMiddleProximalTailNode.attachChild(leftMiddleIntermediateNode)
leftMiddleIntermediateNode.attachChild(leftMiddleDistalNode)
leftHandNode.attachChild(leftRingProximalHeadNode)
leftRingProximalHeadNode.attachChild(leftRingProximalTailNode)
leftRingProximalTailNode.attachChild(leftRingIntermediateNode)
leftRingIntermediateNode.attachChild(leftRingDistalNode)
leftHandNode.attachChild(leftLittleProximalHeadNode)
leftLittleProximalHeadNode.attachChild(leftLittleProximalTailNode)
leftLittleProximalTailNode.attachChild(leftLittleIntermediateNode)
leftLittleIntermediateNode.attachChild(leftLittleDistalNode)
rightHandNode.attachChild(rightThumbProximalHeadNode)
rightThumbProximalHeadNode.attachChild(rightThumbProximalTailNode)
rightThumbProximalTailNode.attachChild(rightThumbIntermediateNode)
rightThumbIntermediateNode.attachChild(rightThumbDistalNode)
rightHandNode.attachChild(rightIndexProximalHeadNode)
rightIndexProximalHeadNode.attachChild(rightIndexProximalTailNode)
rightIndexProximalTailNode.attachChild(rightIndexIntermediateNode)
rightIndexIntermediateNode.attachChild(rightIndexDistalNode)
rightHandNode.attachChild(rightMiddleProximalHeadNode)
rightMiddleProximalHeadNode.attachChild(rightMiddleProximalTailNode)
rightMiddleProximalTailNode.attachChild(rightMiddleIntermediateNode)
rightMiddleIntermediateNode.attachChild(rightMiddleDistalNode)
rightHandNode.attachChild(rightRingProximalHeadNode)
rightRingProximalHeadNode.attachChild(rightRingProximalTailNode)
rightRingProximalTailNode.attachChild(rightRingIntermediateNode)
rightRingIntermediateNode.attachChild(rightRingDistalNode)
rightHandNode.attachChild(rightLittleProximalHeadNode)
rightLittleProximalHeadNode.attachChild(rightLittleProximalTailNode)
rightLittleProximalTailNode.attachChild(rightLittleIntermediateNode)
rightLittleIntermediateNode.attachChild(rightLittleDistalNode)
}
fun update() {
// Set the upper chest node's rotation to the chest's
upperChestNode.localTransform.rotation = chestNode.localTransform.rotation
// Update the root node
hipsNode.update()
}
fun setRootPose(globalPos: Vector3, globalRot: Quaternion) {
rootPosition = globalPos
rootRotation = globalRot
}
fun setGlobalRotationForBone(unityBone: UnityBone, globalRot: Quaternion) {
val node = getHeadNodeOfBone(unityBone)
if (node != null) {
node.localTransform.rotation = if (UnityBone.isLeftArmBone(unityBone)) {
globalRot * LEFT_SHOULDER_OFFSET
} else if (UnityBone.isRightArmBone(unityBone)) {
globalRot * RIGHT_SHOULDER_OFFSET
} else {
globalRot
}
}
}
fun setLocalRotationForBone(unityBone: UnityBone, localRot: Quaternion) {
val node = getHeadNodeOfBone(unityBone)
if (node != null) {
if (unityBone == UnityBone.HIPS) {
node.worldTransform.rotation = localRot
} else {
node.localTransform.rotation = if (UnityBone.isLeftStartOfArmOrFingerBone(unityBone)) {
localRot * RIGHT_SHOULDER_OFFSET
} else if (UnityBone.isRightStartOfArmOrFingerBone(unityBone)) {
localRot * LEFT_SHOULDER_OFFSET
} else {
localRot
}
}
}
}
fun getGlobalTranslationForBone(unityBone: UnityBone): Vector3 {
val node = getHeadNodeOfBone(unityBone)
return if (node != null) {
if (unityBone == UnityBone.HIPS) {
val hipsAverage = (
leftHipNode.worldTransform.translation +
rightHipNode.worldTransform.translation
) *
0.5f
node.worldTransform.translation * 2f - hipsAverage + rootPosition
} else {
node.worldTransform.translation + rootPosition
}
} else {
Vector3.NULL
}
}
fun getLocalTranslationForBone(unityBone: UnityBone): Vector3 {
val node = getHeadNodeOfBone(unityBone)
return if (node != null) {
if (unityBone == UnityBone.HIPS) {
val hipsAverage = (
leftHipNode.worldTransform.translation +
rightHipNode.worldTransform.translation
) *
0.5f
node.worldTransform.translation * 2f - hipsAverage + rootPosition
} else {
node.localTransform.translation
}
} else {
Vector3.NULL
}
}
fun getGlobalRotationForBone(unityBone: UnityBone?): Quaternion {
val node = getHeadNodeOfBone(unityBone)
return if (node != null) {
node.worldTransform.rotation * rootRotation
} else {
Quaternion.IDENTITY
}
}
fun getLocalRotationForBone(unityBone: UnityBone): Quaternion {
val node = getHeadNodeOfBone(unityBone)
return if (node != null) {
if (unityBone == UnityBone.HIPS) {
node.worldTransform.rotation * rootRotation
} else {
node.parent!!.worldTransform.rotation.inv() * node.worldTransform.rotation
}
} else {
Quaternion.IDENTITY
}
}
fun getHeadNodeOfBone(unityBone: UnityBone?): TransformNode? = if (unityBone == null) {
null
} else {
when (unityBone) {
UnityBone.HEAD -> neckTailNode
UnityBone.NECK -> neckHeadNode
UnityBone.UPPER_CHEST -> chestNode
UnityBone.CHEST -> spineTailNode
UnityBone.SPINE -> spineHeadNode
UnityBone.HIPS -> hipsNode
UnityBone.LEFT_UPPER_LEG -> leftHipNode
UnityBone.RIGHT_UPPER_LEG -> rightHipNode
UnityBone.LEFT_LOWER_LEG -> leftKneeNode
UnityBone.RIGHT_LOWER_LEG -> rightKneeNode
UnityBone.LEFT_FOOT -> leftAnkleNode
UnityBone.RIGHT_FOOT -> rightAnkleNode
UnityBone.LEFT_SHOULDER -> leftShoulderHeadNode
UnityBone.RIGHT_SHOULDER -> rightShoulderHeadNode
UnityBone.LEFT_UPPER_ARM -> leftShoulderTailNode
UnityBone.RIGHT_UPPER_ARM -> rightShoulderTailNode
UnityBone.LEFT_LOWER_ARM -> leftElbowNode
UnityBone.RIGHT_LOWER_ARM -> rightElbowNode
UnityBone.LEFT_HAND -> leftWristNode
UnityBone.RIGHT_HAND -> rightWristNode
UnityBone.LEFT_THUMB_PROXIMAL -> leftThumbProximalHeadNode
UnityBone.LEFT_THUMB_INTERMEDIATE -> leftThumbProximalTailNode
UnityBone.LEFT_THUMB_DISTAL -> leftThumbIntermediateNode
UnityBone.LEFT_INDEX_PROXIMAL -> leftIndexProximalHeadNode
UnityBone.LEFT_INDEX_INTERMEDIATE -> leftIndexProximalTailNode
UnityBone.LEFT_INDEX_DISTAL -> leftIndexIntermediateNode
UnityBone.LEFT_MIDDLE_PROXIMAL -> leftMiddleProximalHeadNode
UnityBone.LEFT_MIDDLE_INTERMEDIATE -> leftMiddleProximalTailNode
UnityBone.LEFT_MIDDLE_DISTAL -> leftMiddleIntermediateNode
UnityBone.LEFT_RING_PROXIMAL -> leftRingProximalHeadNode
UnityBone.LEFT_RING_INTERMEDIATE -> leftRingProximalTailNode
UnityBone.LEFT_RING_DISTAL -> leftRingIntermediateNode
UnityBone.LEFT_LITTLE_PROXIMAL -> leftLittleProximalHeadNode
UnityBone.LEFT_LITTLE_INTERMEDIATE -> leftLittleProximalTailNode
UnityBone.LEFT_LITTLE_DISTAL -> leftLittleIntermediateNode
UnityBone.RIGHT_THUMB_PROXIMAL -> rightThumbProximalHeadNode
UnityBone.RIGHT_THUMB_INTERMEDIATE -> rightThumbProximalTailNode
UnityBone.RIGHT_THUMB_DISTAL -> rightThumbIntermediateNode
UnityBone.RIGHT_INDEX_PROXIMAL -> rightIndexProximalHeadNode
UnityBone.RIGHT_INDEX_INTERMEDIATE -> rightIndexProximalTailNode
UnityBone.RIGHT_INDEX_DISTAL -> rightIndexIntermediateNode
UnityBone.RIGHT_MIDDLE_PROXIMAL -> rightMiddleProximalHeadNode
UnityBone.RIGHT_MIDDLE_INTERMEDIATE -> rightMiddleProximalTailNode
UnityBone.RIGHT_MIDDLE_DISTAL -> rightMiddleIntermediateNode
UnityBone.RIGHT_RING_PROXIMAL -> rightRingProximalHeadNode
UnityBone.RIGHT_RING_INTERMEDIATE -> rightRingProximalTailNode
UnityBone.RIGHT_RING_DISTAL -> rightRingIntermediateNode
UnityBone.RIGHT_LITTLE_PROXIMAL -> rightLittleProximalHeadNode
UnityBone.RIGHT_LITTLE_INTERMEDIATE -> rightLittleProximalTailNode
UnityBone.RIGHT_LITTLE_DISTAL -> rightLittleIntermediateNode
else -> null
}
}
companion object {
private val LEFT_SHOULDER_OFFSET = EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion()
private val RIGHT_SHOULDER_OFFSET = EulerAngles(EulerOrder.YZX, 0f, 0f, -FastMath.HALF_PI).toQuaternion()
}
}

View File

@@ -1,346 +0,0 @@
package dev.slimevr.osc
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.trackers.TrackerPosition
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Unity HumanBodyBones from:
* https://docs.unity3d.com/ScriptReference/HumanBodyBones.html
*/
@Serializable
enum class UnityBone(
val stringVal: String,
val boneType: BoneType?,
val trackerPosition: TrackerPosition?,
) {
@SerialName("hips")
HIPS("Hips", BoneType.HIP, TrackerPosition.HIP),
@SerialName("leftUpperLeg")
LEFT_UPPER_LEG("LeftUpperLeg", BoneType.LEFT_UPPER_LEG, TrackerPosition.LEFT_UPPER_LEG),
@SerialName("rightUpperLeg")
RIGHT_UPPER_LEG("RightUpperLeg", BoneType.RIGHT_UPPER_LEG, TrackerPosition.RIGHT_UPPER_LEG),
@SerialName("leftLowerLeg")
LEFT_LOWER_LEG("LeftLowerLeg", BoneType.LEFT_LOWER_LEG, TrackerPosition.LEFT_LOWER_LEG),
@SerialName("rightLowerLeg")
RIGHT_LOWER_LEG("RightLowerLeg", BoneType.RIGHT_LOWER_LEG, TrackerPosition.RIGHT_LOWER_LEG),
@SerialName("leftFoot")
LEFT_FOOT("LeftFoot", BoneType.LEFT_FOOT, TrackerPosition.LEFT_FOOT),
@SerialName("rightFoot")
RIGHT_FOOT("RightFoot", BoneType.RIGHT_FOOT, TrackerPosition.RIGHT_FOOT),
@SerialName("spine")
SPINE("Spine", BoneType.WAIST, TrackerPosition.WAIST),
@SerialName("chest")
CHEST("Chest", BoneType.CHEST, TrackerPosition.CHEST),
@SerialName("upperChest")
UPPER_CHEST("UpperChest", BoneType.CHEST, TrackerPosition.CHEST),
@SerialName("neck")
NECK("Neck", BoneType.NECK, TrackerPosition.NECK),
@SerialName("head")
HEAD("Head", BoneType.HEAD, TrackerPosition.HEAD),
@SerialName("leftShoulder")
LEFT_SHOULDER("LeftShoulder", BoneType.LEFT_SHOULDER, TrackerPosition.LEFT_SHOULDER),
@SerialName("rightShoulder")
RIGHT_SHOULDER("RightShoulder", BoneType.RIGHT_SHOULDER, TrackerPosition.RIGHT_SHOULDER),
@SerialName("leftUpperArm")
LEFT_UPPER_ARM("LeftUpperArm", BoneType.LEFT_UPPER_ARM, TrackerPosition.LEFT_UPPER_ARM),
@SerialName("rightUpperArm")
RIGHT_UPPER_ARM("RightUpperArm", BoneType.RIGHT_UPPER_ARM, TrackerPosition.RIGHT_UPPER_ARM),
@SerialName("leftLowerArm")
LEFT_LOWER_ARM("LeftLowerArm", BoneType.LEFT_LOWER_ARM, TrackerPosition.LEFT_LOWER_ARM),
@SerialName("rightLowerArm")
RIGHT_LOWER_ARM("RightLowerArm", BoneType.RIGHT_LOWER_ARM, TrackerPosition.RIGHT_LOWER_ARM),
@SerialName("leftHand")
LEFT_HAND("LeftHand", BoneType.LEFT_HAND, TrackerPosition.LEFT_HAND),
@SerialName("rightHand")
RIGHT_HAND("RightHand", BoneType.RIGHT_HAND, TrackerPosition.RIGHT_HAND),
@SerialName("leftToes")
LEFT_TOES("LeftToes", null, null),
@SerialName("rightToes")
RIGHT_TOES("RightToes", null, null),
@SerialName("leftEye")
LEFT_EYE("LeftEye", null, null),
@SerialName("rightEye")
RIGHT_EYE("RightEye", null, null),
@SerialName("jaw")
JAW("Jaw", null, null),
@SerialName("leftThumbMetacarpal")
LEFT_THUMB_PROXIMAL("LeftThumbProximal", BoneType.LEFT_THUMB_METACARPAL, TrackerPosition.LEFT_THUMB_METACARPAL),
@SerialName("leftThumbProximal")
LEFT_THUMB_INTERMEDIATE("LeftThumbIntermediate", BoneType.LEFT_THUMB_PROXIMAL, TrackerPosition.LEFT_THUMB_PROXIMAL),
@SerialName("leftThumbDistal")
LEFT_THUMB_DISTAL("LeftThumbDistal", BoneType.LEFT_THUMB_DISTAL, TrackerPosition.LEFT_THUMB_DISTAL),
@SerialName("leftIndexProximal")
LEFT_INDEX_PROXIMAL("LeftIndexProximal", BoneType.LEFT_INDEX_PROXIMAL, TrackerPosition.LEFT_INDEX_PROXIMAL),
@SerialName("leftIndexIntermediate")
LEFT_INDEX_INTERMEDIATE("LeftIndexIntermediate", BoneType.LEFT_INDEX_INTERMEDIATE, TrackerPosition.LEFT_INDEX_INTERMEDIATE),
@SerialName("leftIndexDistal")
LEFT_INDEX_DISTAL("LeftIndexDistal", BoneType.LEFT_INDEX_DISTAL, TrackerPosition.LEFT_INDEX_DISTAL),
@SerialName("leftMiddleProximal")
LEFT_MIDDLE_PROXIMAL("LeftMiddleProximal", BoneType.LEFT_MIDDLE_PROXIMAL, TrackerPosition.LEFT_MIDDLE_PROXIMAL),
@SerialName("leftMiddleIntermediate")
LEFT_MIDDLE_INTERMEDIATE("LeftMiddleIntermediate", BoneType.LEFT_MIDDLE_INTERMEDIATE, TrackerPosition.LEFT_MIDDLE_INTERMEDIATE),
@SerialName("leftMiddleDistal")
LEFT_MIDDLE_DISTAL("LeftMiddleDistal", BoneType.LEFT_MIDDLE_DISTAL, TrackerPosition.LEFT_MIDDLE_DISTAL),
@SerialName("leftRingProximal")
LEFT_RING_PROXIMAL("LeftRingProximal", BoneType.LEFT_RING_PROXIMAL, TrackerPosition.LEFT_RING_PROXIMAL),
@SerialName("leftRingIntermediate")
LEFT_RING_INTERMEDIATE("LeftRingIntermediate", BoneType.LEFT_RING_INTERMEDIATE, TrackerPosition.LEFT_RING_INTERMEDIATE),
@SerialName("leftRingDistal")
LEFT_RING_DISTAL("LeftRingDistal", BoneType.LEFT_RING_DISTAL, TrackerPosition.LEFT_RING_DISTAL),
@SerialName("leftLittleProximal")
LEFT_LITTLE_PROXIMAL("LeftLittleProximal", BoneType.LEFT_LITTLE_PROXIMAL, TrackerPosition.LEFT_LITTLE_PROXIMAL),
@SerialName("leftLittleIntermediate")
LEFT_LITTLE_INTERMEDIATE("LeftLittleIntermediate", BoneType.LEFT_LITTLE_INTERMEDIATE, TrackerPosition.LEFT_LITTLE_INTERMEDIATE),
@SerialName("leftLittleDistal")
LEFT_LITTLE_DISTAL("LeftLittleDistal", BoneType.LEFT_LITTLE_DISTAL, TrackerPosition.LEFT_LITTLE_DISTAL),
@SerialName("rightThumbMetacarpal")
RIGHT_THUMB_PROXIMAL("RightThumbProximal", BoneType.RIGHT_THUMB_METACARPAL, TrackerPosition.RIGHT_THUMB_METACARPAL),
@SerialName("rightThumbProximal")
RIGHT_THUMB_INTERMEDIATE("RightThumbIntermediate", BoneType.RIGHT_THUMB_PROXIMAL, TrackerPosition.RIGHT_THUMB_PROXIMAL),
@SerialName("rightThumbDistal")
RIGHT_THUMB_DISTAL("RightThumbDistal", BoneType.RIGHT_THUMB_DISTAL, TrackerPosition.RIGHT_THUMB_DISTAL),
@SerialName("rightIndexProximal")
RIGHT_INDEX_PROXIMAL("RightIndexProximal", BoneType.RIGHT_INDEX_PROXIMAL, TrackerPosition.RIGHT_INDEX_PROXIMAL),
@SerialName("rightIndexIntermediate")
RIGHT_INDEX_INTERMEDIATE("RightIndexIntermediate", BoneType.RIGHT_INDEX_INTERMEDIATE, TrackerPosition.RIGHT_INDEX_INTERMEDIATE),
@SerialName("rightIndexDistal")
RIGHT_INDEX_DISTAL("RightIndexDistal", BoneType.RIGHT_INDEX_DISTAL, TrackerPosition.RIGHT_INDEX_DISTAL),
@SerialName("rightMiddleProximal")
RIGHT_MIDDLE_PROXIMAL("RightMiddleProximal", BoneType.RIGHT_MIDDLE_PROXIMAL, TrackerPosition.RIGHT_MIDDLE_PROXIMAL),
@SerialName("rightMiddleIntermediate")
RIGHT_MIDDLE_INTERMEDIATE("RightMiddleIntermediate", BoneType.RIGHT_MIDDLE_INTERMEDIATE, TrackerPosition.RIGHT_MIDDLE_INTERMEDIATE),
@SerialName("rightMiddleDistal")
RIGHT_MIDDLE_DISTAL("RightMiddleDistal", BoneType.RIGHT_MIDDLE_DISTAL, TrackerPosition.RIGHT_MIDDLE_DISTAL),
@SerialName("rightRingProximal")
RIGHT_RING_PROXIMAL("RightRingProximal", BoneType.RIGHT_RING_PROXIMAL, TrackerPosition.RIGHT_RING_PROXIMAL),
@SerialName("rightRingIntermediate")
RIGHT_RING_INTERMEDIATE("RightRingIntermediate", BoneType.RIGHT_RING_INTERMEDIATE, TrackerPosition.RIGHT_RING_INTERMEDIATE),
@SerialName("rightRingDistal")
RIGHT_RING_DISTAL("RightRingDistal", BoneType.RIGHT_RING_DISTAL, TrackerPosition.RIGHT_RING_DISTAL),
@SerialName("rightLittleProximal")
RIGHT_LITTLE_PROXIMAL("RightLittleProximal", BoneType.RIGHT_LITTLE_PROXIMAL, TrackerPosition.RIGHT_LITTLE_PROXIMAL),
@SerialName("rightLittleIntermediate")
RIGHT_LITTLE_INTERMEDIATE("RightLittleIntermediate", BoneType.RIGHT_LITTLE_INTERMEDIATE, TrackerPosition.RIGHT_LITTLE_INTERMEDIATE),
@SerialName("rightLittleDistal")
RIGHT_LITTLE_DISTAL("RightLittleDistal", BoneType.RIGHT_LITTLE_DISTAL, TrackerPosition.RIGHT_LITTLE_DISTAL),
LAST_BONE("LastBone", null, null),
;
companion object {
private val byStringVal: Map<String, UnityBone> = values().associateBy { it.stringVal.lowercase() }
@JvmStatic
fun getByStringVal(stringVal: String): UnityBone? = byStringVal[stringVal.lowercase()]
/**
* Returns the bone on the opposite limb, or the original bone if
* it not a limb bone.
*/
fun tryGetOppositeArmBone(bone: UnityBone): UnityBone = when (bone) {
LEFT_SHOULDER -> RIGHT_SHOULDER
LEFT_UPPER_ARM -> RIGHT_UPPER_ARM
LEFT_LOWER_ARM -> RIGHT_LOWER_ARM
LEFT_HAND -> RIGHT_HAND
RIGHT_SHOULDER -> LEFT_SHOULDER
RIGHT_UPPER_ARM -> LEFT_UPPER_ARM
RIGHT_LOWER_ARM -> LEFT_LOWER_ARM
RIGHT_HAND -> LEFT_HAND
LEFT_UPPER_LEG -> RIGHT_UPPER_LEG
LEFT_LOWER_LEG -> RIGHT_LOWER_LEG
LEFT_FOOT -> RIGHT_FOOT
RIGHT_UPPER_LEG -> LEFT_UPPER_LEG
RIGHT_LOWER_LEG -> LEFT_LOWER_LEG
RIGHT_FOOT -> LEFT_FOOT
LEFT_THUMB_PROXIMAL -> RIGHT_THUMB_PROXIMAL
LEFT_THUMB_INTERMEDIATE -> RIGHT_THUMB_INTERMEDIATE
LEFT_THUMB_DISTAL -> RIGHT_THUMB_DISTAL
LEFT_INDEX_PROXIMAL -> RIGHT_INDEX_PROXIMAL
LEFT_INDEX_INTERMEDIATE -> RIGHT_INDEX_INTERMEDIATE
LEFT_INDEX_DISTAL -> RIGHT_INDEX_DISTAL
LEFT_MIDDLE_PROXIMAL -> RIGHT_MIDDLE_PROXIMAL
LEFT_MIDDLE_INTERMEDIATE -> RIGHT_MIDDLE_INTERMEDIATE
LEFT_MIDDLE_DISTAL -> RIGHT_MIDDLE_DISTAL
LEFT_RING_PROXIMAL -> RIGHT_RING_PROXIMAL
LEFT_RING_INTERMEDIATE -> RIGHT_RING_INTERMEDIATE
LEFT_RING_DISTAL -> RIGHT_RING_DISTAL
LEFT_LITTLE_PROXIMAL -> RIGHT_LITTLE_PROXIMAL
LEFT_LITTLE_INTERMEDIATE -> RIGHT_LITTLE_INTERMEDIATE
LEFT_LITTLE_DISTAL -> RIGHT_LITTLE_DISTAL
RIGHT_THUMB_PROXIMAL -> LEFT_THUMB_PROXIMAL
RIGHT_THUMB_INTERMEDIATE -> LEFT_THUMB_INTERMEDIATE
RIGHT_THUMB_DISTAL -> LEFT_THUMB_DISTAL
RIGHT_INDEX_PROXIMAL -> LEFT_INDEX_PROXIMAL
RIGHT_INDEX_INTERMEDIATE -> LEFT_INDEX_INTERMEDIATE
RIGHT_INDEX_DISTAL -> LEFT_INDEX_DISTAL
RIGHT_MIDDLE_PROXIMAL -> LEFT_MIDDLE_PROXIMAL
RIGHT_MIDDLE_INTERMEDIATE -> LEFT_MIDDLE_INTERMEDIATE
RIGHT_MIDDLE_DISTAL -> LEFT_MIDDLE_DISTAL
RIGHT_RING_PROXIMAL -> LEFT_RING_PROXIMAL
RIGHT_RING_INTERMEDIATE -> LEFT_RING_INTERMEDIATE
RIGHT_RING_DISTAL -> LEFT_RING_DISTAL
RIGHT_LITTLE_PROXIMAL -> LEFT_LITTLE_PROXIMAL
RIGHT_LITTLE_INTERMEDIATE -> LEFT_LITTLE_INTERMEDIATE
RIGHT_LITTLE_DISTAL -> LEFT_LITTLE_DISTAL
else -> bone
}
/**
* Returns true if the bone is part of the left arm (incl. fingers, excl. shoulder)
*/
fun isLeftArmBone(bone: UnityBone): Boolean = bone == LEFT_UPPER_ARM ||
bone == LEFT_LOWER_ARM ||
bone == LEFT_HAND ||
bone == LEFT_THUMB_PROXIMAL ||
bone == LEFT_THUMB_INTERMEDIATE ||
bone == LEFT_THUMB_DISTAL ||
bone == LEFT_INDEX_PROXIMAL ||
bone == LEFT_INDEX_INTERMEDIATE ||
bone == LEFT_INDEX_DISTAL ||
bone == LEFT_MIDDLE_PROXIMAL ||
bone == LEFT_MIDDLE_INTERMEDIATE ||
bone == LEFT_MIDDLE_DISTAL ||
bone == LEFT_RING_PROXIMAL ||
bone == LEFT_RING_INTERMEDIATE ||
bone == LEFT_RING_DISTAL ||
bone == LEFT_LITTLE_PROXIMAL ||
bone == LEFT_LITTLE_INTERMEDIATE ||
bone == LEFT_LITTLE_DISTAL
/**
* Returns true if the bone is part of the right arm (incl. fingers, excl. shoulder)
*/
fun isRightArmBone(bone: UnityBone): Boolean = bone == RIGHT_UPPER_ARM ||
bone == RIGHT_LOWER_ARM ||
bone == RIGHT_HAND ||
bone == RIGHT_THUMB_PROXIMAL ||
bone == RIGHT_THUMB_INTERMEDIATE ||
bone == RIGHT_THUMB_DISTAL ||
bone == RIGHT_INDEX_PROXIMAL ||
bone == RIGHT_INDEX_INTERMEDIATE ||
bone == RIGHT_INDEX_DISTAL ||
bone == RIGHT_MIDDLE_PROXIMAL ||
bone == RIGHT_MIDDLE_INTERMEDIATE ||
bone == RIGHT_MIDDLE_DISTAL ||
bone == RIGHT_RING_PROXIMAL ||
bone == RIGHT_RING_INTERMEDIATE ||
bone == RIGHT_RING_DISTAL ||
bone == RIGHT_LITTLE_PROXIMAL ||
bone == RIGHT_LITTLE_INTERMEDIATE ||
bone == RIGHT_LITTLE_DISTAL
/**
* Returns true if the bone is the left upper arm or proximal left finger bone
*/
fun isLeftStartOfArmOrFingerBone(bone: UnityBone): Boolean = bone == LEFT_UPPER_ARM ||
bone == LEFT_THUMB_PROXIMAL ||
bone == LEFT_INDEX_PROXIMAL ||
bone == LEFT_MIDDLE_PROXIMAL ||
bone == LEFT_RING_PROXIMAL ||
bone == LEFT_LITTLE_PROXIMAL
/**
* Returns true if the bone is the right upper arm or proximal right finger bone
*/
fun isRightStartOfArmOrFingerBone(bone: UnityBone): Boolean = bone == RIGHT_UPPER_ARM ||
bone == RIGHT_THUMB_PROXIMAL ||
bone == RIGHT_INDEX_PROXIMAL ||
bone == RIGHT_MIDDLE_PROXIMAL ||
bone == RIGHT_RING_PROXIMAL ||
bone == RIGHT_LITTLE_PROXIMAL
/**
* Returns true if the bone is part of the left fingers
*/
fun isLeftFingerBone(bone: UnityBone): Boolean = bone == LEFT_THUMB_PROXIMAL ||
bone == LEFT_THUMB_INTERMEDIATE ||
bone == LEFT_THUMB_DISTAL ||
bone == LEFT_INDEX_PROXIMAL ||
bone == LEFT_INDEX_INTERMEDIATE ||
bone == LEFT_INDEX_DISTAL ||
bone == LEFT_MIDDLE_PROXIMAL ||
bone == LEFT_MIDDLE_INTERMEDIATE ||
bone == LEFT_MIDDLE_DISTAL ||
bone == LEFT_RING_PROXIMAL ||
bone == LEFT_RING_INTERMEDIATE ||
bone == LEFT_RING_DISTAL ||
bone == LEFT_LITTLE_PROXIMAL ||
bone == LEFT_LITTLE_INTERMEDIATE ||
bone == LEFT_LITTLE_DISTAL
/**
* Returns true if the bone part of the right fingers
*/
fun isRightFingerBone(bone: UnityBone): Boolean = bone == RIGHT_THUMB_PROXIMAL ||
bone == RIGHT_THUMB_INTERMEDIATE ||
bone == RIGHT_THUMB_DISTAL ||
bone == RIGHT_INDEX_PROXIMAL ||
bone == RIGHT_INDEX_INTERMEDIATE ||
bone == RIGHT_INDEX_DISTAL ||
bone == RIGHT_MIDDLE_PROXIMAL ||
bone == RIGHT_MIDDLE_INTERMEDIATE ||
bone == RIGHT_MIDDLE_DISTAL ||
bone == RIGHT_RING_PROXIMAL ||
bone == RIGHT_RING_INTERMEDIATE ||
bone == RIGHT_RING_DISTAL ||
bone == RIGHT_LITTLE_PROXIMAL ||
bone == RIGHT_LITTLE_INTERMEDIATE ||
bone == RIGHT_LITTLE_DISTAL
}
}

View File

@@ -1,511 +0,0 @@
package dev.slimevr.osc
import com.illposed.osc.OSCBundle
import com.illposed.osc.OSCMessage
import com.illposed.osc.OSCMessageEvent
import com.illposed.osc.OSCMessageListener
import com.illposed.osc.OSCSerializeException
import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector
import com.illposed.osc.transport.OSCPortIn
import com.illposed.osc.transport.OSCPortOut
import dev.slimevr.VRServer
import dev.slimevr.VRServer.Companion.currentLocalTrackerId
import dev.slimevr.VRServer.Companion.getNextLocalTrackerId
import dev.slimevr.config.VMCConfig
import dev.slimevr.osc.UnityBone.Companion.getByStringVal
import dev.slimevr.tracking.processor.BoneType
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.trackers.Device
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerPosition
import dev.slimevr.tracking.trackers.TrackerStatus
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY
import io.github.axisangles.ktmath.Vector3
import io.github.axisangles.ktmath.Vector3.Companion.NULL
import io.github.axisangles.ktmath.Vector3.Companion.POS_Y
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
/**
* VMC documentation: https://protocol.vmc.info/english
*
*
* Notes: VMC uses local rotation from hip (unlike SlimeVR, which uses rotations
* from head). VMC works with Unity's coordinate system, which means
* Quaternions' z and w components and Vectors' z components need to be inverse
*/
class VMCHandler(
private val server: VRServer,
private val humanPoseManager: HumanPoseManager,
private val config: VMCConfig,
) : OSCHandler {
private var oscReceiver: OSCPortIn? = null
private var oscSender: OSCPortOut? = null
private val computedTrackers: MutableList<Tracker> = FastList()
private val oscArgs = FastList<Any?>()
private val startTime = System.currentTimeMillis()
private val byTrackerNameTracker: MutableMap<String, Tracker> = HashMap()
private var yawOffset = IDENTITY
private var inputUnityArmature: UnityArmature? = null
private var outputUnityArmature: UnityArmature? = null
private var vrmHeight = 0f
private var trackerDevice: Device? = null
private var timeAtLastError: Long = 0
private var timeAtLastSend: Long = 0
private var anchorHip = false
private var mirrorTracking = false
private var lastPortIn = 0
private var lastPortOut = 0
private var lastAddress: InetAddress? = null
init {
refreshSettings(false)
}
override fun refreshSettings(refreshRouterSettings: Boolean) {
anchorHip = config.anchorHip
mirrorTracking = config.mirrorTracking
updateOscReceiver(
config.portIn,
arrayOf(
"/VMC/Ext/Bone/Pos",
"/VMC/Ext/Hmd/Pos",
"/VMC/Ext/Con/Pos",
"/VMC/Ext/Tra/Pos",
"/VMC/Ext/Root/Pos",
),
)
updateOscSender(config.portOut, config.address)
if (config.enabled) {
// Load VRM data
if (outputUnityArmature != null && config.vrmJson != null) {
val vrmReader = VRMReader(config.vrmJson!!)
for (unityBone in UnityBone.entries) {
val node = outputUnityArmature!!.getHeadNodeOfBone(unityBone)
if (node != null) {
val offset = if (unityBone == UnityBone.HIPS) {
// For the hip bone, add average of upper leg offsets (which are negative). The hip bone's offset is global.
vrmReader.getOffsetForBone(UnityBone.HIPS) +
((vrmReader.getOffsetForBone(UnityBone.LEFT_UPPER_LEG) + vrmReader.getOffsetForBone(UnityBone.RIGHT_UPPER_LEG)) / 2f)
} else {
vrmReader.getOffsetForBone(unityBone)
}
node.localTransform.translation = offset
}
}
// Make sure to account for the upper legs because of the hip.
vrmHeight = (
vrmReader.getOffsetForBone(UnityBone.HIPS) +
((vrmReader.getOffsetForBone(UnityBone.LEFT_UPPER_LEG) + vrmReader.getOffsetForBone(UnityBone.RIGHT_UPPER_LEG))) +
vrmReader.getOffsetForBone(UnityBone.SPINE) +
vrmReader.getOffsetForBone(UnityBone.CHEST) +
vrmReader.getOffsetForBone(UnityBone.UPPER_CHEST) +
vrmReader.getOffsetForBone(UnityBone.NECK) +
vrmReader.getOffsetForBone(UnityBone.HEAD)
).y
}
}
if (refreshRouterSettings) server.oSCRouter.refreshSettings(false)
}
override fun updateOscReceiver(portIn: Int, args: Array<String>) {
// Stops listening and closes OSC port
val wasListening = oscReceiver != null && oscReceiver!!.isListening
if (wasListening) {
oscReceiver!!.stopListening()
}
if (config.enabled) {
// Instantiates the OSC receiver
try {
oscReceiver = OSCPortIn(portIn)
if (lastPortIn != portIn || !wasListening) {
LogManager.info("[VMCHandler] Listening to port $portIn")
}
lastPortIn = portIn
} catch (e: IOException) {
LogManager
.severe(
"[VMCHandler] Error listening to the port $portIn: $e",
)
}
// Starts listening for VMC messages
if (oscReceiver != null) {
val listener = OSCMessageListener { event: OSCMessageEvent -> this.handleReceivedMessage(event) }
for (address in args) {
oscReceiver!!
.dispatcher
.addListener(OSCPatternAddressMessageSelector(address), listener)
}
oscReceiver!!.startListening()
}
}
}
override fun updateOscSender(portOut: Int, ip: String) {
// Stop sending
val wasConnected = oscSender != null && oscSender!!.isConnected
if (wasConnected) {
try {
oscSender!!.close()
} catch (e: IOException) {
LogManager.severe("[VMCHandler] Error closing the OSC sender: $e")
}
}
if (config.enabled) {
// Instantiate the OSC sender
try {
val addr = InetAddress.getByName(ip)
oscSender = OSCPortOut(InetSocketAddress(addr, portOut))
if ((lastPortOut != portOut && lastAddress != addr) || !wasConnected) {
LogManager
.info(
"[VMCHandler] Sending to port $portOut at address $ip",
)
}
lastPortOut = portOut
lastAddress = addr
oscSender!!.connect()
outputUnityArmature = UnityArmature(false)
} catch (e: IOException) {
LogManager
.severe(
"[VMCHandler] Error connecting to port $portOut at the address $ip: $e",
)
}
}
}
private fun handleReceivedMessage(event: OSCMessageEvent) {
when (event.message.address) {
// Is bone (rotation)
"/VMC/Ext/Bone/Pos" -> {
var trackerPosition: TrackerPosition? = null
val bone = getByStringVal(event.message.arguments[0].toString())
if (bone != null) trackerPosition = bone.trackerPosition
// If received bone is part of SlimeVR's skeleton
if (trackerPosition != null) {
handleReceivedTracker(
"VMC-Bone-" + event.message.arguments[0],
trackerPosition,
null,
Quaternion(
-(event.message.arguments[7] as Float),
event.message.arguments[4] as Float,
event.message.arguments[5] as Float,
-(event.message.arguments[6] as Float),
),
true,
getByStringVal(
event.message.arguments[0].toString(),
),
)
}
}
// Is tracker (position + rotation)
"/VMC/Ext/Hmd/Pos", "/VMC/Ext/Con/Pos", "/VMC/Ext/Tra/Pos" ->
handleReceivedTracker(
"VMC-Tracker-" + event.message.arguments[0],
null,
Vector3(
event.message.arguments[1] as Float,
event.message.arguments[2] as Float,
-(event.message.arguments[3] as Float),
),
Quaternion(
-(event.message.arguments[7] as Float),
event.message.arguments[4] as Float,
event.message.arguments[5] as Float,
-(event.message.arguments[6] as Float),
),
false,
null,
)
// Is VMC tracking root (offsets all rotations)
"/VMC/Ext/Root/Pos" -> {
if (inputUnityArmature != null) {
inputUnityArmature!!
.setRootPose(
Vector3(
event.message.arguments[1] as Float,
event.message.arguments[2] as Float,
-(event.message.arguments[3] as Float),
),
Quaternion(
-(event.message.arguments[7] as Float),
event.message.arguments[4] as Float,
event.message.arguments[5] as Float,
-(event.message.arguments[6] as Float),
),
)
}
}
}
}
private fun handleReceivedTracker(
name: String,
trackerPosition: TrackerPosition?,
position: Vector3?,
rotation: Quaternion,
localRotation: Boolean,
unityBone: UnityBone?,
) {
// Create device if it doesn't exist
var rot = rotation
if (trackerDevice == null) {
trackerDevice = server.deviceManager.createDevice("VMC receiver", "1.0", "VMC")
server.deviceManager.addDevice(trackerDevice!!)
}
// Try to get tracker
var tracker = byTrackerNameTracker[name]
// Create tracker if trying to get it returned null
if (tracker == null) {
tracker = Tracker(
trackerDevice,
getNextLocalTrackerId(),
name,
"VMC Tracker #$currentLocalTrackerId",
trackerPosition,
hasPosition = position != null,
hasRotation = true,
userEditable = true,
isComputed = position != null,
usesTimeout = true,
allowReset = position != null,
)
trackerDevice!!.trackers[trackerDevice!!.trackers.size] = tracker
byTrackerNameTracker[name] = tracker
server.registerTracker(tracker)
}
tracker.status = TrackerStatus.OK
// Set position
if (position != null) {
tracker.position = position
}
// Set rotation
if (localRotation) {
// Instantiate unityHierarchy if not done
if (inputUnityArmature == null) inputUnityArmature = UnityArmature(true)
inputUnityArmature!!.setLocalRotationForBone(unityBone!!, rot)
rot = inputUnityArmature!!.getGlobalRotationForBone(unityBone)
rot = yawOffset.times(rot)
}
tracker.setRotation(rot)
tracker.dataTick()
}
override fun update() {
// Update unity hierarchy
if (inputUnityArmature != null) inputUnityArmature!!.update()
val currentTime = System.currentTimeMillis()
if (currentTime - timeAtLastSend > 3) { // 200hz to not crash VSF
timeAtLastSend = currentTime
// Send OSC data
if (oscSender != null && oscSender!!.isConnected) {
// Create new OSC Bundle
val oscBundle = OSCBundle()
// Add our relative time
oscArgs.clear()
oscArgs.add((System.currentTimeMillis() - startTime) / 1000f)
oscBundle.addPacket(OSCMessage("/VMC/Ext/T", oscArgs.clone()))
if (humanPoseManager.isSkeletonPresent) {
// Indicate tracking is available
oscArgs.clear()
oscArgs.add(1)
oscBundle.addPacket(OSCMessage("/VMC/Ext/OK", oscArgs.clone()))
oscArgs.clear()
oscArgs.add("root")
addTransformToArgs(NULL, IDENTITY)
oscBundle.addPacket(OSCMessage("/VMC/Ext/Root/Pos", oscArgs.clone()))
for (unityBone in UnityBone.entries) {
// Get opposite bone if tracking must be mirrored
val boneType = (if (mirrorTracking) UnityBone.tryGetOppositeArmBone(unityBone) else unityBone).boneType
if (boneType == null) continue
// Get SlimeVR bone
val bone = humanPoseManager.getBone(boneType)
// Update unity hierarchy from bone's global rotation
val boneRotation = if (mirrorTracking) {
// Mirror tracking horizontally
val rotBuf = bone.getGlobalRotation() * bone.rotationOffset.inv()
Quaternion(rotBuf.w, rotBuf.x, -rotBuf.y, -rotBuf.z)
} else {
bone.getGlobalRotation() * bone.rotationOffset.inv()
}
outputUnityArmature?.setGlobalRotationForBone(unityBone, boneRotation)
}
if (!anchorHip) {
// Anchor from head
outputUnityArmature?.let { unityArmature ->
// Scale the SlimeVR neck position with the VRM model
// We're only getting the height up to the neck because we don't want to factor the neck's length into the scaling
val slimevrScaledRootPos = humanPoseManager.getBone(BoneType.NECK).getTailPosition() *
(vrmHeight / humanPoseManager.userNeckHeightFromConfig)
// Get the VRM head and hip positions
val vrmHeadPos = unityArmature.getHeadNodeOfBone(UnityBone.HEAD)!!.parent!!.worldTransform.translation
val vrmHipPos = unityArmature.getHeadNodeOfBone(UnityBone.HIPS)!!.worldTransform.translation
// Calculate the new VRM hip position by subtracting the difference head-hip distance from the SlimeVR head
val calculatedVrmHipPos = slimevrScaledRootPos - (vrmHeadPos - vrmHipPos)
// Set the VRM's hip position
unityArmature.getHeadNodeOfBone(UnityBone.HIPS)?.localTransform?.translation = calculatedVrmHipPos
}
}
// Update Unity skeleton
outputUnityArmature?.update()
// Add Unity humanoid bones transforms
for (unityBone in UnityBone.entries) {
// Don't send bones for which we don't have an equivalent
// Don't send fingers if we don't have any tracker for them
// Don't send arm bones if we're tracking from the controller
if (unityBone.boneType != null &&
(!UnityBone.isLeftFingerBone(unityBone) || humanPoseManager.skeleton.hasLeftFingerTracker || (mirrorTracking && humanPoseManager.skeleton.hasRightFingerTracker)) &&
(!UnityBone.isRightFingerBone(unityBone) || humanPoseManager.skeleton.hasRightFingerTracker || (mirrorTracking && humanPoseManager.skeleton.hasLeftFingerTracker)) &&
!(humanPoseManager.isTrackingLeftArmFromController && (UnityBone.isLeftArmBone(unityBone) || unityBone == UnityBone.LEFT_SHOULDER)) &&
!(humanPoseManager.isTrackingRightArmFromController && (UnityBone.isRightArmBone(unityBone) || unityBone == UnityBone.RIGHT_SHOULDER))
) {
oscArgs.clear()
oscArgs.add(unityBone.stringVal)
outputUnityArmature?.let {
addTransformToArgs(
it.getLocalTranslationForBone(unityBone),
it.getLocalRotationForBone(unityBone),
)
}
oscBundle.addPacket(OSCMessage("/VMC/Ext/Bone/Pos", oscArgs.clone()))
}
}
}
for (tracker in computedTrackers) {
if (!tracker.status.reset) {
oscArgs.clear()
val name = tracker.name
oscArgs.add(name)
addTransformToArgs(
tracker.position,
tracker.getRotation(),
)
var address: String
val role = tracker.trackerPosition
address = if (role == TrackerPosition.HEAD) {
"/VMC/Ext/Hmd/Pos"
} else if (role == TrackerPosition.LEFT_HAND || role == TrackerPosition.RIGHT_HAND) {
"/VMC/Ext/Con/Pos"
} else {
"/VMC/Ext/Tra/Pos"
}
oscBundle
.addPacket(
OSCMessage(
address,
oscArgs.clone(),
),
)
}
}
// Send OSC packets as bundle
try {
oscSender!!.send(oscBundle)
} catch (e: IOException) {
// Avoid spamming AsynchronousCloseException too many
// times per second
if (System.currentTimeMillis() - timeAtLastError > 100) {
timeAtLastError = System.currentTimeMillis()
LogManager
.warning(
"[VMCHandler] Error sending OSC packets: " +
e,
)
}
} catch (e: OSCSerializeException) {
if (System.currentTimeMillis() - timeAtLastError > 100) {
timeAtLastError = System.currentTimeMillis()
LogManager
.warning(
"[VMCHandler] Error sending OSC packets: " +
e,
)
}
}
}
}
}
/**
* Set the Quaternion to shift the received VMC tracking rotations' yaw
*
* @param reference the head's rotation
*/
fun alignVMCTracking(reference: Quaternion) {
yawOffset = reference.project(POS_Y).unit()
}
/**
* Add a computed tracker to the list of trackers to send.
*
* @param computedTracker the computed tracker
*/
fun addComputedTracker(computedTracker: Tracker) {
computedTrackers.add(computedTracker)
}
private fun addTransformToArgs(pos: Vector3, rot: Quaternion) {
oscArgs.add(pos.x)
oscArgs.add(pos.y)
oscArgs.add(-pos.z)
oscArgs.add(rot.x)
oscArgs.add(rot.y)
oscArgs.add(-rot.z)
oscArgs.add(-rot.w)
}
override fun getOscSender(): OSCPortOut = oscSender!!
override fun getPortOut(): Int = lastPortOut
override fun getAddress(): InetAddress = lastAddress!!
override fun getOscReceiver(): OSCPortIn = oscReceiver!!
override fun getPortIn(): Int = lastPortIn
}

View File

@@ -1,560 +0,0 @@
package dev.slimevr.osc
import com.illposed.osc.OSCBundle
import com.illposed.osc.OSCMessage
import com.illposed.osc.OSCMessageEvent
import com.illposed.osc.OSCMessageListener
import com.illposed.osc.OSCSerializeException
import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector
import com.illposed.osc.transport.OSCPortIn
import com.illposed.osc.transport.OSCPortOut
import com.jme3.math.FastMath
import com.jme3.system.NanoTimer
import dev.slimevr.VRServer
import dev.slimevr.config.VRCOSCConfig
import dev.slimevr.protocol.rpc.setup.RPCUtil
import dev.slimevr.tracking.trackers.Device
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerPosition
import dev.slimevr.tracking.trackers.TrackerStatus
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import io.github.axisangles.ktmath.EulerAngles
import io.github.axisangles.ktmath.EulerOrder
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Vector3
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
private const val OFFSET_SLERP_FACTOR = 0.5f // Guessed from eyeing VRChat
/**
* VRChat OSCTracker documentation: https://docs.vrchat.com/docs/osc-trackers
*/
class VRCOSCHandler(
private val server: VRServer,
private val config: VRCOSCConfig,
private val computedTrackers: List<Tracker>,
) : OSCHandler {
private val localIp = RPCUtil.getLocalIp()
private val loopbackIp = InetAddress.getLoopbackAddress().hostAddress
private val vrsystemTrackersAddresses = arrayOf(
"/tracking/vrsystem/head/pose",
"/tracking/vrsystem/leftwrist/pose",
"/tracking/vrsystem/rightwrist/pose",
)
private val oscTrackersAddresses = arrayOf(
"/tracking/trackers/*/position",
"/tracking/trackers/*/rotation",
)
private var oscReceiver: OSCPortIn? = null
private var oscSender: OSCPortOut? = null
private var oscQuerySender: OSCPortOut? = null
private var oscMessage: OSCMessage? = null
private var headTracker: Tracker? = null
private var oscTrackersDevice: Device? = null
private var vrsystemTrackersDevice: Device? = null
private val oscArgs = FastList<Float?>(3)
private val trackersEnabled: BooleanArray = BooleanArray(computedTrackers.size)
private var oscPortIn = 0
private var oscPortOut = 0
private var oscIp: InetAddress? = null
private var oscQuerySenderState = false
private var oscQueryPortOut = 0
private var oscQueryIp: String? = null
private var timeAtLastError: Long = 0
private var receivingPositionOffset = Vector3.NULL
private var postReceivingPositionOffset = Vector3.NULL
private var receivingRotationOffset = Quaternion.IDENTITY
private var receivingRotationOffsetGoal = Quaternion.IDENTITY
private val postReceivingOffset = EulerAngles(EulerOrder.YXZ, 0f, FastMath.PI, 0f).toQuaternion()
private var timeAtLastReceivedRotationOffset = System.currentTimeMillis()
private var fpsTimer: NanoTimer? = null
private var vrcOscQueryHandler: VRCOSCQueryHandler? = null
init {
refreshSettings(false)
}
override fun refreshSettings(refreshRouterSettings: Boolean) {
// Sets which trackers are enabled and force head and hands to false
for (i in computedTrackers.indices) {
if (computedTrackers[i].trackerPosition != TrackerPosition.HEAD || computedTrackers[i].trackerPosition != TrackerPosition.LEFT_HAND || computedTrackers[i].trackerPosition != TrackerPosition.RIGHT_HAND) {
trackersEnabled[i] = config
.getOSCTrackerRole(
computedTrackers[i].trackerPosition!!.trackerRole!!,
false,
)
} else {
trackersEnabled[i] = false
}
}
updateOscReceiver(config.portIn, vrsystemTrackersAddresses + oscTrackersAddresses)
updateOscSender(config.portOut, config.address)
if (vrcOscQueryHandler == null && config.enabled && config.oscqueryEnabled) {
try {
vrcOscQueryHandler = VRCOSCQueryHandler(this)
} catch (e: Throwable) {
LogManager.severe("Unable to initialize OSCQuery: $e", e)
}
} else if (vrcOscQueryHandler != null && (!config.enabled || !config.oscqueryEnabled)) {
vrcOscQueryHandler?.close()
vrcOscQueryHandler = null
}
if (refreshRouterSettings) {
server.oSCRouter.refreshSettings(false)
}
}
/**
* Adds an OSC Sender from OSCQuery
*/
fun addOSCQuerySender(oscPortOut: Int, oscIP: String) {
val addr = InetAddress.getByName(oscIP)
oscQuerySenderState = true
oscQueryIp = oscIP
oscQueryPortOut = oscPortOut
if (oscPortOut != portOut || (oscIP != address.hostName && !(oscIP == localIp && address.hostName == loopbackIp))) {
try {
oscQuerySender = OSCPortOut(InetSocketAddress(addr, oscPortOut))
oscQuerySender?.connect()
LogManager.info("[VRCOSCHandler] OSCQuery sender sending to port $oscPortOut at address $oscIP")
} catch (e: IOException) {
LogManager.severe("[VRCOSCHandler] Error connecting to port $oscPortOut at the address $oscIP: $e")
}
}
}
/**
* Close/remove the osc query sender
*/
fun closeOscQuerySender(newState: Boolean) {
oscQuerySender?.let {
try {
it.close()
oscQuerySender = null
oscQuerySenderState = newState
} catch (e: IOException) {
LogManager.severe("[VRCOSCHandler] Error closing the OSC sender: $e")
}
}
}
override fun updateOscReceiver(portIn: Int, args: Array<String>) {
// Stop listening
val wasListening = oscReceiver != null && oscReceiver!!.isListening
if (wasListening) {
oscReceiver!!.stopListening()
}
if (config.enabled) {
// Instantiates the OSC receiver
try {
oscReceiver = OSCPortIn(portIn)
if (oscPortIn != portIn || !wasListening) {
LogManager.info("[VRCOSCHandler] Listening to port $portIn")
}
oscPortIn = portIn
vrcOscQueryHandler?.updateOSCQuery(portIn.toUShort())
} catch (e: IOException) {
LogManager
.severe(
"[VRCOSCHandler] Error listening to the port $portIn: $e",
)
}
// Starts listening for VRC or OSCTrackers messages
oscReceiver?.let {
val listener = OSCMessageListener { event: OSCMessageEvent ->
handleReceivedMessage(event)
}
for (address in args) {
it.dispatcher.addListener(
OSCPatternAddressMessageSelector(address),
listener,
)
}
it.startListening()
}
}
}
override fun updateOscSender(portOut: Int, ip: String) {
// Stop sending
val wasConnected = oscSender != null && oscSender!!.isConnected
if (wasConnected) {
try {
oscSender!!.close()
} catch (e: IOException) {
LogManager.severe("[VRCOSCHandler] Error closing the OSC sender: $e")
}
}
if (config.enabled) {
// Instantiate the OSC sender
try {
val addr = InetAddress.getByName(ip)
oscSender = OSCPortOut(InetSocketAddress(addr, portOut))
if ((oscPortOut != portOut && oscIp != addr) || !wasConnected) {
LogManager.info("[VRCOSCHandler] Sending to port $portOut at address $ip")
}
oscPortOut = portOut
oscIp = addr
oscSender?.connect()
} catch (e: IOException) {
LogManager
.severe(
"[VRCOSCHandler] Error connecting to port $portOut at the address $ip: $e",
)
return
}
if (oscQueryPortOut == portOut && (oscQueryIp == ip || (oscQueryIp == localIp && ip == loopbackIp))) {
if (oscQuerySender != null) {
// Close the oscQuerySender if it has the same port/ip
closeOscQuerySender(true)
}
} else if (oscQuerySender == null && oscQuerySenderState) {
// Instantiate the oscQuerySender if it could not be instantiated.
addOSCQuerySender(oscQueryPortOut, oscQueryIp!!)
}
}
}
private fun handleReceivedMessage(event: OSCMessageEvent) {
if (vrsystemTrackersAddresses.contains(event.message.address)) {
// Receiving Head and Wrist pose data thanks to OSCQuery
// Create device if it doesn't exist
if (vrsystemTrackersDevice == null) {
// Instantiate OSC Trackers device
vrsystemTrackersDevice = server.deviceManager.createDevice("VRC VRSystem", null, "VRChat")
server.deviceManager.addDevice(vrsystemTrackersDevice!!)
}
// Look at xxx in "/tracking/vrsystem/xxx/pose" to know TrackerPosition
var name = "VRChat "
val trackerPosition = when (event.message.address.split('/')[3]) {
"head" -> {
name += "head"
TrackerPosition.HEAD
}
"leftwrist" -> {
name += "left hand"
TrackerPosition.LEFT_HAND
}
"rightwrist" -> {
name += "right hand"
TrackerPosition.RIGHT_HAND
}
else -> {
LogManager.warning("[VRCOSCHandler] Received invalid body part in message \"${event.message.address}\"")
return
}
}
// Try to get the tracker
var tracker = vrsystemTrackersDevice!!.trackers[trackerPosition.ordinal]
// Build the tracker if it doesn't exist
if (tracker == null) {
tracker = Tracker(
device = vrsystemTrackersDevice,
id = VRServer.getNextLocalTrackerId(),
name = name,
displayName = name,
trackerNum = trackerPosition.ordinal,
trackerPosition = trackerPosition,
hasRotation = true,
hasPosition = true,
userEditable = true,
isComputed = true,
allowReset = trackerPosition != TrackerPosition.HEAD,
usesTimeout = true,
)
vrsystemTrackersDevice!!.trackers[trackerPosition.ordinal] = tracker
server.registerTracker(tracker)
}
// Sets the tracker status to OK
tracker.status = TrackerStatus.OK
// Update tracker position
tracker.position = Vector3(
event.message.arguments[0] as Float,
event.message.arguments[1] as Float,
-(event.message.arguments[2] as Float),
)
// Update tracker rotation
val (w, x, y, z) = EulerAngles(
EulerOrder.YXZ,
event.message.arguments[3] as Float * FastMath.DEG_TO_RAD,
event.message.arguments[4] as Float * FastMath.DEG_TO_RAD,
event.message.arguments[5] as Float * FastMath.DEG_TO_RAD,
).toQuaternion()
val rot = Quaternion(w, -x, -y, z)
tracker.setRotation(rot)
tracker.dataTick()
} else {
// Receiving OSC Trackers data. This is not from VRChat.
if (oscTrackersDevice == null) {
// Instantiate OSC Trackers device
oscTrackersDevice = server.deviceManager.createDevice("OSC Tracker", null, "OSC Trackers")
server.deviceManager.addDevice(oscTrackersDevice!!)
}
// Extract the xxx in "/tracking/trackers/xxx/..."
val splitAddress = event.message.address.split('/')
val trackerStringValue = splitAddress[3]
val dataType = event.message.address.split('/')[4]
if (trackerStringValue == "head") {
// Head data
if (dataType == "position") {
// Position offset
receivingPositionOffset = Vector3(
event.message.arguments[0] as Float,
event.message.arguments[1] as Float,
-(event.message.arguments[2] as Float),
)
headTracker?.let {
if (it.hasPosition) {
postReceivingPositionOffset = it.position
}
}
} else {
// Rotation offset
val (w, x, y, z) = EulerAngles(EulerOrder.YXZ, event.message.arguments[0] as Float * FastMath.DEG_TO_RAD, event.message.arguments[1] as Float * FastMath.DEG_TO_RAD, event.message.arguments[2] as Float * FastMath.DEG_TO_RAD).toQuaternion()
receivingRotationOffsetGoal = Quaternion(w, -x, -y, z).inv()
headTracker.let {
receivingRotationOffsetGoal = if (it != null && it.hasRotation) {
it.getRotation().project(Vector3.POS_Y).unit() * receivingRotationOffsetGoal
} else {
receivingRotationOffsetGoal
}
}
// If greater than 300ms, snap to rotation
if (System.currentTimeMillis() - timeAtLastReceivedRotationOffset > 300) {
receivingRotationOffset = receivingRotationOffsetGoal
}
// Update time variable
timeAtLastReceivedRotationOffset = System.currentTimeMillis()
}
} else {
// Trackers data (1-8)
val trackerId = trackerStringValue.toInt()
var tracker = oscTrackersDevice!!.trackers[trackerId]
if (tracker == null) {
tracker = Tracker(
device = oscTrackersDevice,
id = VRServer.getNextLocalTrackerId(),
name = "OSC Tracker #$trackerId",
displayName = "OSC Tracker #$trackerId",
trackerNum = trackerId,
trackerPosition = null,
hasRotation = true,
hasPosition = true,
userEditable = true,
isComputed = true,
allowReset = true,
usesTimeout = true,
)
oscTrackersDevice!!.trackers[trackerId] = tracker
server.registerTracker(tracker)
}
// Sets the tracker status to OK
tracker.status = TrackerStatus.OK
if (dataType == "position") {
// Update tracker position
tracker.position = receivingRotationOffset.sandwich(
Vector3(
event.message.arguments[0] as Float,
event.message.arguments[1] as Float,
-(event.message.arguments[2] as Float),
) -
receivingPositionOffset,
) +
postReceivingPositionOffset
} else {
// Update tracker rotation
val (w, x, y, z) = EulerAngles(
EulerOrder.YXZ,
event.message.arguments[0] as Float * FastMath.DEG_TO_RAD,
event.message.arguments[1] as Float * FastMath.DEG_TO_RAD,
event.message.arguments[2] as Float * FastMath.DEG_TO_RAD,
).toQuaternion()
val rot = Quaternion(w, -x, -y, z)
tracker.setRotation(receivingRotationOffset * rot * postReceivingOffset)
}
tracker.dataTick()
}
}
}
override fun update() {
// Gets timer from vrServer
if (fpsTimer == null) {
fpsTimer = VRServer.instance.fpsTimer
}
// Update received trackers' offset rotation slerp
if (receivingRotationOffset != receivingRotationOffsetGoal) {
receivingRotationOffset = receivingRotationOffset.interpR(receivingRotationOffsetGoal, OFFSET_SLERP_FACTOR * (fpsTimer?.timePerFrame ?: 1f))
}
// Update current time
val currentTime = System.currentTimeMillis().toFloat()
// Send OSC data
if (oscSender != null && oscSender!!.isConnected) {
// Create new bundle
val bundle = OSCBundle()
for (i in computedTrackers.indices) {
if (trackersEnabled[i]) {
// Send regular trackers' positions
val (x, y, z) = computedTrackers[i].position
oscArgs.clear()
oscArgs.add(x)
oscArgs.add(y)
oscArgs.add(-z)
bundle.addPacket(
OSCMessage(
"/tracking/trackers/${getVRCOSCTrackersId(computedTrackers[i].trackerPosition)}/position",
oscArgs.clone(),
),
)
// Send regular trackers' rotations
val (w, x1, y1, z1) = computedTrackers[i].getRotation()
// We flip the X and Y components of the quaternion because
// we flip the z direction when communicating from
// our right-handed API to VRChat's left-handed API.
// X quaternion represents a rotation from y to z
// Y quaternion represents a rotation from z to x
// When we negate the z direction, X and Y quaternion
// components must be negated.
val (_, x2, y2, z2) = Quaternion(
w,
-x1,
-y1,
z1,
).toEulerAngles(EulerOrder.YXZ)
oscArgs.clear()
oscArgs.add(x2 * FastMath.RAD_TO_DEG)
oscArgs.add(y2 * FastMath.RAD_TO_DEG)
oscArgs.add(z2 * FastMath.RAD_TO_DEG)
bundle.addPacket(
OSCMessage(
"/tracking/trackers/${getVRCOSCTrackersId(computedTrackers[i].trackerPosition)}/rotation",
oscArgs.clone(),
),
)
}
if (computedTrackers[i].trackerPosition == TrackerPosition.HEAD) {
// Send HMD position
val (x, y, z) = computedTrackers[i].position
oscArgs.clear()
oscArgs.add(x)
oscArgs.add(y)
oscArgs.add(-z)
bundle.addPacket(
OSCMessage(
"/tracking/trackers/head/position",
oscArgs.clone(),
),
)
}
}
try {
oscSender?.send(bundle)
oscQuerySender?.send(bundle)
} catch (e: IOException) {
// Avoid spamming AsynchronousCloseException too many
// times per second
if (currentTime - timeAtLastError > 100) {
timeAtLastError = System.currentTimeMillis()
LogManager.warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e")
}
} catch (e: OSCSerializeException) {
if (currentTime - timeAtLastError > 100) {
timeAtLastError = System.currentTimeMillis()
LogManager.warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e")
}
}
}
}
private fun getVRCOSCTrackersId(trackerPosition: TrackerPosition?): Int {
// Needs to range from 1-8.
// Don't change as third party applications may rely
// on this for mapping trackers to body parts.
return when (trackerPosition) {
TrackerPosition.HIP -> 1
TrackerPosition.LEFT_FOOT -> 2
TrackerPosition.RIGHT_FOOT -> 3
TrackerPosition.LEFT_UPPER_LEG -> 4
TrackerPosition.RIGHT_UPPER_LEG -> 5
TrackerPosition.UPPER_CHEST -> 6
TrackerPosition.LEFT_UPPER_ARM -> 7
TrackerPosition.RIGHT_UPPER_ARM -> 8
else -> -1
}
}
fun setHeadTracker(headTracker: Tracker?) {
this.headTracker = headTracker
}
/**
* Sends the expected HMD rotation upon reset to align the trackers in VRC
*/
fun yawAlign(headRot: Quaternion) {
if (oscSender != null && oscSender!!.isConnected) {
val (_, _, y, _) = headRot.toEulerAngles(EulerOrder.YXZ)
oscArgs.clear()
oscArgs.add(0f)
oscArgs.add(-y * FastMath.RAD_TO_DEG)
oscArgs.add(0f)
oscMessage = OSCMessage(
"/tracking/trackers/head/rotation",
oscArgs,
)
try {
oscSender?.send(oscMessage)
oscQuerySender?.send(oscMessage)
} catch (e: IOException) {
LogManager
.warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e")
} catch (e: OSCSerializeException) {
LogManager
.warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e")
}
}
}
override fun getOscSender(): OSCPortOut = oscSender!!
override fun getPortOut(): Int = oscPortOut
override fun getAddress(): InetAddress = oscIp!!
override fun getOscReceiver(): OSCPortIn = oscReceiver!!
override fun getPortIn(): Int = oscPortIn
}

View File

@@ -1,86 +0,0 @@
package dev.slimevr.osc
import OSCQueryNode
import OSCQueryServer
import ServiceInfo
import dev.slimevr.protocol.rpc.setup.RPCUtil
import io.eiren.util.logging.LogManager
import randomFreePort
import java.io.IOException
import kotlin.concurrent.thread
private const val serviceStartsWith = "VRChat-Client"
private const val queryPath = "/tracking/vrsystem"
/**
* Handler for OSCQuery for VRChat using our library
* https://github.com/SlimeVR/oscquery-kt
*/
class VRCOSCQueryHandler(
private val vrcOscHandler: VRCOSCHandler,
) {
private val oscQueryServer: OSCQueryServer
init {
// Request data
val localIp = RPCUtil.getLocalIp() ?: throw IllegalStateException("No local IP address found for OSCQuery to bind to")
val httpPort = randomFreePort()
oscQueryServer = OSCQueryServer(
"SlimeVR-Server-$httpPort",
OscTransport.UDP,
localIp,
vrcOscHandler.portIn.toUShort(),
httpPort,
)
oscQueryServer.rootNode.addNode(OSCQueryNode(queryPath))
oscQueryServer.init()
LogManager.info("[VRCOSCQueryHandler] SlimeVR OSCQueryServer started at http://$localIp:$httpPort")
try {
// Add service listener
LogManager.info("[VRCOSCQueryHandler] Listening for VRChat OSCQuery")
oscQueryServer.service.addServiceListener(
"_osc._udp.local.",
onServiceAdded = ::serviceAdded,
)
} catch (e: IOException) {
LogManager.warning("[VRCOSCQueryHandler] " + e.message)
}
}
/**
* Updates the OSC service's port
*/
fun updateOSCQuery(port: UShort) {
if (oscQueryServer.oscPort != port) {
thread(start = true) {
oscQueryServer.updateOscService(port)
}
}
}
/**
* Called when a service is added
*/
private fun serviceAdded(info: ServiceInfo) {
// Check the service name
if (!info.name.startsWith(serviceStartsWith)) return
// Get url from ServiceInfo
val ip = info.inetAddresses[0].hostAddress
val port = info.port
// create a new OSCHandler for this service
vrcOscHandler.addOSCQuerySender(port, ip)
}
/**
* Closes the OSCQueryServer and the associated OSC sender.
*/
fun close() {
vrcOscHandler.closeOscQuerySender(false)
thread(start = true) {
oscQueryServer.close()
}
}
}

View File

@@ -1,103 +0,0 @@
package dev.slimevr.osc
import io.eiren.util.logging.LogManager
import io.github.axisangles.ktmath.Vector3
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.*
private val jsonIgnoreKeys = Json { ignoreUnknownKeys = true }
class VRMReader(vrmJson: String) {
private val data: GLTF = jsonIgnoreKeys.decodeFromString(vrmJson)
fun getOffsetForBone(unityBone: UnityBone): Vector3 {
val node = try {
if (data.extensions.vrmV1 != null) {
if (data.extensions.vrmV1.specVersion != "1.0") {
LogManager.warning("[VRMReader] VRM version is not 1.0")
}
data.extensions.vrmV1.humanoid.humanBones.getValue(unityBone).node
} else {
data.extensions.vrmV0?.humanoid?.humanBones?.first {
it.bone.equals(unityBone.stringVal, ignoreCase = true)
}?.node
}
} catch (_: NoSuchElementException) {
LogManager.warning("[VRMReader] Bone ${unityBone.stringVal} not found in JSON")
null
} ?: return Vector3.NULL
val translationNode = data.nodes[node].translation ?: return Vector3.NULL
return Vector3(translationNode[0].toFloat(), translationNode[1].toFloat(), translationNode[2].toFloat())
}
}
@Serializable
data class GLTF(
val extensions: Extensions,
val extensionsUsed: List<String>,
val nodes: List<Node>,
)
@Serializable
data class Extensions(
@SerialName("VRM")
val vrmV0: VRMV0? = null,
@SerialName("VRMC_vrm")
val vrmV1: VRMV1? = null,
)
@Serializable
data class VRMV1(
val specVersion: String,
val humanoid: HumanoidV1,
)
@Serializable
data class HumanoidV1(
val humanBones: Map<UnityBone, HumanBoneV1>,
)
@Serializable
data class HumanBoneV1(
val node: Int,
)
@Serializable
data class VRMV0(
val humanoid: HumanoidV0,
)
@Serializable
data class HumanoidV0(
val humanBones: List<HumanBoneV0>,
val armStretch: Double,
val legStretch: Double,
val upperArmTwist: Double,
val lowerArmTwist: Double,
val upperLegTwist: Double,
val lowerLegTwist: Double,
val feetSpacing: Double,
val hasTranslationDoF: Boolean,
)
@Serializable
data class HumanBoneV0(
val bone: String,
val node: Int,
val useDefaultValues: Boolean,
)
@Serializable
data class Node(
val translation: List<Double>? = null,
val rotation: List<Double>? = null,
val scale: List<Double>? = null,
// GLTF says that there can be a matrix instead of translation,
// rotation and scale, so we need to support that too in the future.
// val matrix: List<List<Double>>,
val children: List<Int> = emptyList(),
)

View File

@@ -1,189 +0,0 @@
package dev.slimevr.poseframeformat
import dev.slimevr.poseframeformat.trackerdata.TrackerFrame
import dev.slimevr.poseframeformat.trackerdata.TrackerFrameData
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.trackers.TrackerPosition
import dev.slimevr.tracking.trackers.TrackerPosition.Companion.getByDesignation
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Vector3
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
object PfrIO {
@Throws(IOException::class)
private fun writeVector3f(outputStream: DataOutputStream, vector: Vector3) {
outputStream.writeFloat(vector.x)
outputStream.writeFloat(vector.y)
outputStream.writeFloat(vector.z)
}
@Throws(IOException::class)
private fun writeQuaternion(outputStream: DataOutputStream, quaternion: Quaternion) {
outputStream.writeFloat(quaternion.x)
outputStream.writeFloat(quaternion.y)
outputStream.writeFloat(quaternion.z)
outputStream.writeFloat(quaternion.w)
}
fun writeFrame(outputStream: DataOutputStream, trackerFrame: TrackerFrame?) {
if (trackerFrame == null) {
outputStream.writeInt(0)
return
}
var dataFlags = trackerFrame.dataFlags
// Don't write destination strings anymore, replace with
// the enum
if (trackerFrame.hasData(TrackerFrameData.DESIGNATION_STRING)) {
dataFlags = TrackerFrameData.TRACKER_POSITION_ENUM
.add(TrackerFrameData.DESIGNATION_STRING.remove(dataFlags))
}
outputStream.writeInt(dataFlags)
if (trackerFrame.hasData(TrackerFrameData.ROTATION)) {
writeQuaternion(outputStream, trackerFrame.rotation!!)
}
if (trackerFrame.hasData(TrackerFrameData.POSITION)) {
writeVector3f(outputStream, trackerFrame.position!!)
}
if (TrackerFrameData.TRACKER_POSITION_ENUM.check(dataFlags)) {
// ID is offset by 1 for historical reasons
outputStream.writeInt(trackerFrame.trackerPosition!!.id - 1)
}
if (trackerFrame.hasData(TrackerFrameData.ACCELERATION)) {
writeVector3f(outputStream, trackerFrame.acceleration!!)
}
if (trackerFrame.hasData(TrackerFrameData.RAW_ROTATION)) {
writeQuaternion(outputStream, trackerFrame.rawRotation!!)
}
}
fun writeFrames(outputStream: DataOutputStream, frames: PoseFrames) {
outputStream.writeInt(frames.frameHolders.size)
for (tracker in frames.frameHolders) {
outputStream.writeUTF(tracker.name)
outputStream.writeInt(tracker.frames.size)
for (i in 0 until tracker.frames.size) {
writeFrame(outputStream, tracker.tryGetFrame(i))
}
}
}
fun tryWriteFrames(outputStream: DataOutputStream, frames: PoseFrames): Boolean = try {
writeFrames(outputStream, frames)
true
} catch (e: Exception) {
LogManager.severe("Error writing frame to stream.", e)
false
}
fun writeToFile(file: File, frames: PoseFrames) {
DataOutputStream(
BufferedOutputStream(FileOutputStream(file)),
).use { writeFrames(it, frames) }
}
fun tryWriteToFile(file: File, frames: PoseFrames): Boolean = try {
writeToFile(file, frames)
true
} catch (e: Exception) {
LogManager.severe("Error writing frames to file.", e)
false
}
@Throws(IOException::class)
private fun readVector3f(inputStream: DataInputStream): Vector3 = Vector3(
inputStream.readFloat(),
inputStream.readFloat(),
inputStream.readFloat(),
)
@Throws(IOException::class)
private fun readQuaternion(inputStream: DataInputStream): Quaternion {
val x = inputStream.readFloat()
val y = inputStream.readFloat()
val z = inputStream.readFloat()
val w = inputStream.readFloat()
return Quaternion(w, x, y, z)
}
fun readFrame(inputStream: DataInputStream): TrackerFrame {
val dataFlags = inputStream.readInt()
var designation: TrackerPosition? = null
if (TrackerFrameData.DESIGNATION_STRING.check(dataFlags)) {
designation = getByDesignation(inputStream.readUTF())
}
var rotation: Quaternion? = null
if (TrackerFrameData.ROTATION.check(dataFlags)) {
rotation = readQuaternion(inputStream)
}
var position: Vector3? = null
if (TrackerFrameData.POSITION.check(dataFlags)) {
position = readVector3f(inputStream)
}
if (TrackerFrameData.TRACKER_POSITION_ENUM.check(dataFlags)) {
// ID is offset by 1 for historical reasons
designation = TrackerPosition.getById(inputStream.readInt() + 1)
}
var acceleration: Vector3? = null
if (TrackerFrameData.ACCELERATION.check(dataFlags)) {
acceleration = readVector3f(inputStream)
}
var rawRotation: Quaternion? = null
if (TrackerFrameData.RAW_ROTATION.check(dataFlags)) {
rawRotation = readQuaternion(inputStream)
}
return TrackerFrame(
designation,
rotation,
position,
acceleration,
rawRotation,
)
}
fun readFrames(inputStream: DataInputStream): PoseFrames {
val trackerCount = inputStream.readInt()
val trackers = FastList<TrackerFrames>(trackerCount)
for (i in 0 until trackerCount) {
val name = inputStream.readUTF()
val trackerFrameCount = inputStream.readInt()
val trackerFrames = FastList<TrackerFrame?>(
trackerFrameCount,
)
for (j in 0 until trackerFrameCount) {
trackerFrames.add(readFrame(inputStream))
}
trackers.add(TrackerFrames(name, trackerFrames))
}
return PoseFrames(trackers)
}
fun tryReadFrames(inputStream: DataInputStream): PoseFrames? = try {
readFrames(inputStream)
} catch (e: Exception) {
LogManager.severe("Error reading frames from stream.", e)
null
}
fun readFromFile(file: File): PoseFrames = DataInputStream(BufferedInputStream(FileInputStream(file))).use { readFrames(it) }
fun tryReadFromFile(file: File): PoseFrames? = try {
readFromFile(file)
} catch (e: Exception) {
LogManager.severe("Error reading frames from file.", e)
null
}
}

View File

@@ -1,197 +0,0 @@
package dev.slimevr.poseframeformat
import dev.slimevr.config.SkeletonConfig
import dev.slimevr.poseframeformat.trackerdata.TrackerFrame
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import io.eiren.util.logging.LogManager
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.EOFException
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
/**
* PoseFrameStream File IO, designed to handle the internal PoseFrames format with
* the new file format for streaming and storing additional debugging info
*/
object PfsIO {
private fun writeRecordingDef(stream: DataOutputStream, frameInterval: Float) {
stream.writeByte(PfsPackets.RECORDING_DEFINITION.id)
stream.writeFloat(frameInterval)
}
private fun writeTrackerDef(stream: DataOutputStream, id: Int, name: String) {
stream.writeByte(PfsPackets.TRACKER_DEFINITION.id)
stream.writeByte(id)
stream.writeUTF(name)
}
private fun writeTrackerFrame(stream: DataOutputStream, id: Int, frameIndex: Int, frame: TrackerFrame) {
stream.writeByte(PfsPackets.TRACKER_FRAME.id)
stream.writeByte(id)
stream.writeInt(frameIndex)
// Write frame data (same format as PFR)
PfrIO.writeFrame(stream, frame)
}
private fun writeBodyProportions(stream: DataOutputStream, skeletonConfig: SkeletonConfig) {
stream.writeByte(PfsPackets.PROPORTIONS_CONFIG.id)
// HMD height
stream.writeFloat(skeletonConfig.hmdHeight)
// Floor height
stream.writeFloat(skeletonConfig.floorHeight)
// Write config map
stream.writeShort(skeletonConfig.offsets.size)
for ((key, value) in skeletonConfig.offsets) {
stream.writeUTF(key)
stream.writeFloat(value)
}
}
fun writeFrames(stream: DataOutputStream, frames: PoseFrames) {
// Give trackers IDs (max 255)
val trackers = frames.frameHolders.mapIndexed { i, t -> i to t }
// Write recording definition
writeRecordingDef(stream, frames.frameInterval)
// Write tracker definitions
for (tracker in trackers) {
writeTrackerDef(stream, tracker.first, tracker.second.name)
}
// Write tracker frames
for (i in 0 until frames.maxFrameCount) {
for (tracker in trackers) {
// If the tracker has a frame at the index
val frame = tracker.second.tryGetFrame(i)
if (frame != null) {
writeTrackerFrame(stream, tracker.first, i, frame)
}
}
}
}
fun tryWriteFrames(stream: DataOutputStream, frames: PoseFrames): Boolean = try {
writeFrames(stream, frames)
true
} catch (e: Exception) {
LogManager.severe("[PfsIO] Error writing frame to stream.", e)
false
}
fun writeToFile(file: File, frames: PoseFrames) {
DataOutputStream(
BufferedOutputStream(FileOutputStream(file)),
).use { writeFrames(it, frames) }
}
fun tryWriteToFile(file: File, frames: PoseFrames): Boolean = try {
writeToFile(file, frames)
true
} catch (e: Exception) {
LogManager.severe("[PfsIO] Error writing frames to file.", e)
false
}
fun readFrame(stream: DataInputStream, poseFrames: PoseFrames, trackers: MutableMap<Int, TrackerFrames>) {
val packetId = stream.readUnsignedByte()
val packetType = PfsPackets.byId[packetId]
when (packetType) {
null -> {
throw IOException("Encountered unknown packet ID ($packetId) while deserializing PFS stream.")
}
PfsPackets.RECORDING_DEFINITION -> {
// Unused, useful for debugging
val frameInterval = stream.readFloat()
poseFrames.frameInterval = frameInterval
LogManager.debug("[PfsIO] Frame interval: $frameInterval s")
}
PfsPackets.TRACKER_DEFINITION -> {
val trackerId = stream.readUnsignedByte()
val name = stream.readUTF()
// Get or make tracker and set its name
trackers.getOrPut(trackerId) {
TrackerFrames(name)
}.name = name
}
PfsPackets.TRACKER_FRAME -> {
val trackerId = stream.readUnsignedByte()
val tracker = trackers.getOrPut(trackerId) {
// If tracker doesn't exist yet, make one
TrackerFrames()
}
val frameNum = stream.readInt()
val frame = PfrIO.readFrame(stream)
tracker.frames.add(frameNum, frame)
}
PfsPackets.PROPORTIONS_CONFIG -> {
// Unused, useful for debugging
val hmdHeight = stream.readFloat()
val floorHeight = stream.readFloat()
LogManager.debug("[PfsIO] HMD height: $hmdHeight, Floor height: $floorHeight")
// Currently just prints JSON format config to console
val configCount = stream.readUnsignedShort()
val sb = StringBuilder("[PfsIO] Body proportion configs ($configCount): {")
for (i in 0 until configCount) {
if (i > 0) {
sb.append(", ")
}
sb.append(stream.readUTF())
sb.append(": ")
sb.append(stream.readFloat())
}
sb.append('}')
LogManager.debug(sb.toString())
}
}
}
fun readFrames(stream: DataInputStream): PoseFrames {
val poseFrames = PoseFrames()
val trackers = mutableMapOf<Int, TrackerFrames>()
while (true) {
try {
readFrame(stream, poseFrames, trackers)
} catch (_: EOFException) {
// Reached end of stream, stop reading and return the recording
// LogManager.debug("[PfsIO] Reached end of PFS stream.", e)
break
}
}
poseFrames.frameHolders.addAll(trackers.values)
return poseFrames
}
fun tryReadFrames(stream: DataInputStream): PoseFrames? = try {
readFrames(stream)
} catch (e: Exception) {
LogManager.severe("[PfsIO] Error reading frames from stream.", e)
null
}
fun readFromFile(file: File): PoseFrames = DataInputStream(BufferedInputStream(FileInputStream(file))).use { readFrames(it) }
fun tryReadFromFile(file: File): PoseFrames? = try {
readFromFile(file)
} catch (e: Exception) {
LogManager.severe("[PfsIO] Error reading frames from file.", e)
null
}
}

View File

@@ -1,40 +0,0 @@
package dev.slimevr.poseframeformat
/**
* Packet ID ([UByte]),
* Packet data (see [PfsPackets], implemented in [PfsIO])
*/
enum class PfsPackets(val id: Int) {
/**
* Frame interval ([Float] seconds)
*/
RECORDING_DEFINITION(0),
/**
* Tracker ID ([UByte]),
* Tracker name (UTF-8 [String])
*/
TRACKER_DEFINITION(1),
/**
* Tracker ID ([UByte]),
* Frame number ([UInt]),
* PFR frame data (see [PfrIO.writeFrame] & [PfrIO.readFrame])
*/
TRACKER_FRAME(2),
/**
* Hmd height ([Float]),
* Floor height ([Float]),
* Body proportion configs (Count ([UShort]) x (Key (UTF-8 [String]), Value ([Float]))
*/
PROPORTIONS_CONFIG(3),
;
val byteId = id.toUByte()
companion object {
val byId = entries.associateBy { it.id }
val byByteId = entries.associateBy { it.byteId }
}
}

View File

@@ -1,164 +0,0 @@
package dev.slimevr.poseframeformat
import dev.slimevr.poseframeformat.trackerdata.TrackerFrame
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.trackers.TrackerPosition
import io.eiren.util.collections.FastList
class PoseFrames : Iterable<Array<TrackerFrame?>> {
val frameHolders: FastList<TrackerFrames>
/**
* Frame interval in seconds
*/
var frameInterval: Float = 0.02f
/**
* Creates a [PoseFrames] object with the provided list of
* [TrackerFrames]s as the internal [TrackerFrames] list.
*
* @see [FastList]
* @see [TrackerFrames]
*/
constructor(frameHolders: FastList<TrackerFrames>) {
this.frameHolders = frameHolders
}
/**
* Creates a [PoseFrames] object with the specified initial tracker
* capacity.
*
* @see [PoseFrames]
*/
constructor(initialCapacity: Int = 5) {
frameHolders = FastList(initialCapacity)
}
/**
* @return The [TrackerFrames] associated with [position] at frame
* index [index].
*/
fun getTrackerForPosition(position: TrackerPosition, index: Int = 0): TrackerFrames? {
for (tracker in frameHolders) {
if (tracker.tryGetFrame(index)?.trackerPosition == position) return tracker
}
return null
}
// region Data Utilities
/**
* @return The maximum Y value of the [TrackerFrames] associated with the
* [TrackerPosition.HEAD] tracker position on the first frame, otherwise `0f` if
* no [TrackerFrames] is associated with [TrackerPosition.HEAD] or if there are no
* valid positions.
* @see [getMaxHeight]
*/
val maxHmdHeight: Float
get() {
return getMaxHeight(
getTrackerForPosition(TrackerPosition.HEAD)
?: return 0f,
)
}
/**
* @return The maximum Y value of the [trackerFrames], otherwise `0f` if
* there are no valid positions.
* @see [TrackerPosition]
*/
fun getMaxHeight(trackerFrames: TrackerFrames): Float {
var maxHeight = 0f
for (frame in trackerFrames.frames) {
val framePosition = frame?.tryGetPosition() ?: continue
if (framePosition.y > maxHeight) {
maxHeight = framePosition.y
}
}
return maxHeight
}
// endregion
/**
* @return The maximum number of [TrackerFrame]s contained within each
* [TrackerFrames] in the internal [TrackerFrames] list.
* @see [TrackerFrames.frames]
* @see [List.size]
*/
val maxFrameCount: Int
get() {
return frameHolders.maxOfOrNull { tracker -> tracker.frames.size } ?: 0
}
/**
* Using the provided array buffer, get the [TrackerFrame]s contained
* within each [TrackerFrames] in the internal
* [TrackerFrames] list at the specified index.
*
* @return The number of frames written to the buffer.
* @see [TrackerFrames.tryGetFrame]
*/
fun getFrames(frameIndex: Int, buffer: Array<TrackerFrame?>): Int {
var frameCount = 0
for (tracker in frameHolders) {
if (tracker == null) {
continue
}
val frame = tracker.tryGetFrame(frameIndex) ?: continue
buffer[frameCount++] = frame
}
return frameCount
}
/**
* Using the provided [List] buffer, get the [TrackerFrame]s
* contained within each [TrackerFrames] in the internal
* [TrackerFrames] list at the specified index.
*
* @return The number of frames written to the buffer.
* @see [TrackerFrames.tryGetFrame]
*/
fun getFrames(frameIndex: Int, buffer: MutableList<TrackerFrame?>): Int {
var frameCount = 0
for (tracker in frameHolders) {
if (tracker == null) {
continue
}
val frame = tracker.tryGetFrame(frameIndex) ?: continue
buffer[frameCount++] = frame
}
return frameCount
}
/**
* @return The [TrackerFrame]s contained within each
* [TrackerFrames] in the internal [TrackerFrames] list at
* the specified index.
* @see [TrackerFrames.tryGetFrame]
*/
fun getFrames(frameIndex: Int): Array<TrackerFrame?> {
val trackerFrames = arrayOfNulls<TrackerFrame>(frameHolders.size)
getFrames(frameIndex, trackerFrames)
return trackerFrames
}
override fun iterator(): Iterator<Array<TrackerFrame?>> = PoseFrameIterator(this)
inner class PoseFrameIterator(private val poseFrame: PoseFrames) : Iterator<Array<TrackerFrame?>> {
private val trackerFrameBuffer: Array<TrackerFrame?> = arrayOfNulls(poseFrame.frameHolders.size)
private val maxCursor = poseFrame.maxFrameCount
private var cursor = 0
override fun hasNext(): Boolean = frameHolders.isNotEmpty() && cursor < maxCursor
override fun next(): Array<TrackerFrame?> {
if (!hasNext()) {
throw NoSuchElementException()
}
poseFrame.getFrames(cursor++, trackerFrameBuffer)
return trackerFrameBuffer
}
}
}

View File

@@ -1,162 +0,0 @@
package dev.slimevr.poseframeformat
import dev.slimevr.VRServer
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.util.TickReducer
import dev.slimevr.util.ann.VRServerThread
import io.eiren.util.collections.FastList
import io.eiren.util.logging.LogManager
import org.apache.commons.lang3.tuple.Pair
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.function.Consumer
class PoseRecorder(private val server: VRServer) {
inner class RecordingProgress(val frame: Int, val totalFrames: Int)
private var poseFrame: PoseFrames? = null
private var numFrames = -1
private var frameCursor = 0
// Default 50 TPS
private val ticker = TickReducer({ onTick() }, 0.02f)
private var recordingFuture: CompletableFuture<PoseFrames>? = null
private var frameCallback: Consumer<RecordingProgress>? = null
var trackers = FastList<Pair<Tracker, TrackerFrames>>()
init {
server.addOnTick {
if (numFrames > 0) {
ticker.tick(server.fpsTimer.timePerFrame)
}
}
}
// Make sure it's synchronized since this is the server thread interacting with
// an unknown outside thread controlling this class
@Synchronized
@VRServerThread
fun onTick() {
if (frameCursor >= numFrames) {
// If done and hasn't yet, send finished recording
stopFrameRecording()
return
}
// A stopped recording will be accounted for by an empty "trackers" list
val cursor = frameCursor++
for (tracker in trackers) {
// Add a frame for each tracker
tracker.right.addFrameFromTracker(cursor, tracker.left)
}
// Send the number of finished frames
frameCallback?.accept(RecordingProgress(frameCursor, numFrames))
// If done, send finished recording
if (frameCursor >= numFrames) {
stopFrameRecording()
}
}
@Synchronized
fun startFrameRecording(
numFrames: Int,
interval: Float,
trackers: List<Tracker?> = server.allTrackers,
frameCallback: Consumer<RecordingProgress>? = null,
): Future<PoseFrames> {
require(numFrames >= 1) { "numFrames must at least have a value of 1." }
require(interval > 0) { "interval must be greater than 0." }
require(trackers.isNotEmpty()) { "trackers must have at least one entry." }
cancelFrameRecording()
val poseFrame = PoseFrames(trackers.size)
poseFrame.frameInterval = interval
// Update tracker list
this.trackers.ensureCapacity(trackers.size)
for (tracker in trackers) {
// Ignore null and internal trackers
if (tracker == null || tracker.isInternal) {
continue
}
// Create a tracker recording
val trackerFrames = TrackerFrames(tracker, numFrames)
poseFrame.frameHolders.add(trackerFrames)
// Pair tracker with recording
this.trackers.add(Pair.of(tracker, trackerFrames))
}
require(this.trackers.isNotEmpty()) { "trackers must have at least one valid tracker." }
// Ticking setup
ticker.interval = interval
ticker.reset()
val recordingFuture = CompletableFuture<PoseFrames>()
this.recordingFuture = recordingFuture
this.frameCallback = frameCallback
// Recording setup
this.poseFrame = poseFrame
frameCursor = 0
this.numFrames = numFrames
LogManager.info(
"[PoseRecorder] Recording $numFrames samples at a $interval s frame interval",
)
return recordingFuture
}
@Synchronized
private fun internalStopFrameRecording(cancel: Boolean) {
val currentRecording = recordingFuture
if (currentRecording != null && !currentRecording.isDone) {
val currentFrames = poseFrame
if (cancel || currentFrames == null) {
// If it's supposed to be cancelled or there's actually no recording,
// then cancel the recording and return nothing
currentRecording.cancel(true)
} else {
// Stop the recording, returning the frames recorded
currentRecording.complete(currentFrames)
}
}
numFrames = -1
frameCursor = 0
trackers.clear()
poseFrame = null
}
@Synchronized
fun stopFrameRecording() {
internalStopFrameRecording(false)
}
@Synchronized
fun cancelFrameRecording() {
internalStopFrameRecording(true)
}
val isReadyToRecord: Boolean
get() = server.trackersCount > 0
val isRecording: Boolean
get() = numFrames > frameCursor
fun hasRecording(): Boolean = recordingFuture != null
val framesAsync: Future<PoseFrames>?
get() = recordingFuture
@get:Throws(ExecutionException::class, InterruptedException::class)
val frames: PoseFrames?
get() {
return recordingFuture?.get()
}
}

View File

@@ -1,72 +0,0 @@
package dev.slimevr.poseframeformat.player
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.trackers.Tracker
class PlayerTracker(val trackerFrames: TrackerFrames, val tracker: Tracker, private var internalCursor: Int = 0, private var internalScale: Float = 1f) {
var cursor: Int
get() = internalCursor
set(value) {
val limitedCursor = limitCursor(value)
internalCursor = limitedCursor
setTrackerStateFromIndex(limitedCursor)
}
var scale: Float
get() = internalScale
set(value) {
internalScale = value
setTrackerStateFromIndex()
}
init {
setTrackerStateFromIndex(limitCursor())
}
fun limitCursor(cursor: Int): Int {
return if (cursor < 0 || trackerFrames.frames.isEmpty()) {
return 0
} else if (cursor >= trackerFrames.frames.size) {
return trackerFrames.frames.size - 1
} else {
cursor
}
}
fun limitCursor(): Int {
val limitedCursor = limitCursor(internalCursor)
internalCursor = limitedCursor
return limitedCursor
}
private fun setTrackerStateFromIndex(index: Int = internalCursor) {
val frame = trackerFrames.tryGetFrame(index) ?: return
/*
* TODO: No way to set adjusted rotation manually? That might be nice to have...
* for now we'll stick with just setting the final rotation as raw and not
* enabling any adjustments
*/
val trackerPosition = frame.tryGetTrackerPosition()
if (trackerPosition != null) {
tracker.trackerPosition = trackerPosition
}
val rotation = frame.tryGetRotation()
if (rotation != null) {
tracker.setRotation(rotation)
}
val position = frame.tryGetPosition()
if (position != null) {
tracker.position = position * internalScale
}
val acceleration = frame.tryGetAcceleration()
if (acceleration != null) {
tracker.setAcceleration(acceleration * internalScale)
}
}
}

View File

@@ -1,44 +0,0 @@
package dev.slimevr.poseframeformat.player
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.poseframeformat.trackerdata.TrackerFrame
import dev.slimevr.poseframeformat.trackerdata.TrackerFrames
import dev.slimevr.tracking.trackers.Tracker
class TrackerFramesPlayer(vararg val frameHolders: TrackerFrames) {
val playerTrackers: Array<PlayerTracker> = frameHolders.map { trackerFrames ->
PlayerTracker(
trackerFrames,
trackerFrames.toTracker(),
)
}.toTypedArray()
val trackers: Array<Tracker> =
playerTrackers.map { playerTracker -> playerTracker.tracker }.toTypedArray()
/**
* @return The maximum number of [TrackerFrame]s contained within each
* [TrackerFrames] in the internal [TrackerFrames] array.
* @see [TrackerFrames.frames]
* @see [List.size]
*/
val maxFrameCount: Int
get() {
return frameHolders.maxOfOrNull { tracker -> tracker.frames.size } ?: 0
}
constructor(poseFrames: PoseFrames) : this(frameHolders = poseFrames.frameHolders.toTypedArray())
fun setCursors(index: Int) {
for (playerTracker in playerTrackers) {
playerTracker.cursor = index
}
}
fun setScales(scale: Float) {
for (playerTracker in playerTrackers) {
playerTracker.scale = scale
}
}
}

View File

@@ -1,117 +0,0 @@
package dev.slimevr.poseframeformat.trackerdata
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerPosition
import dev.slimevr.tracking.trackers.TrackerStatus
import io.github.axisangles.ktmath.Quaternion
import io.github.axisangles.ktmath.Vector3
data class TrackerFrame(
val trackerPosition: TrackerPosition? = null,
val rotation: Quaternion? = null,
val position: Vector3? = null,
val acceleration: Vector3? = null,
val rawRotation: Quaternion? = null,
) {
val dataFlags: Int
val name: String
get() = "TrackerFrame:/${trackerPosition?.designation ?: "null"}"
init {
var initDataFlags = 0
if (trackerPosition != null) {
initDataFlags = TrackerFrameData.TRACKER_POSITION_ENUM.add(initDataFlags)
}
if (rotation != null) {
initDataFlags = TrackerFrameData.ROTATION.add(initDataFlags)
}
if (position != null) {
initDataFlags = TrackerFrameData.POSITION.add(initDataFlags)
}
if (acceleration != null) {
initDataFlags = TrackerFrameData.ACCELERATION.add(initDataFlags)
}
if (rawRotation != null) {
initDataFlags = TrackerFrameData.RAW_ROTATION.add(initDataFlags)
}
dataFlags = initDataFlags
}
fun hasData(flag: TrackerFrameData): Boolean = flag.check(dataFlags)
// region Tracker Try Getters
fun tryGetTrackerPosition(): TrackerPosition? = if (hasData(TrackerFrameData.TRACKER_POSITION_ENUM) || hasData(TrackerFrameData.DESIGNATION_STRING)) {
trackerPosition
} else {
null
}
fun tryGetRotation(): Quaternion? = if (hasData(TrackerFrameData.ROTATION)) {
rotation
} else {
null
}
fun tryGetRawRotation(): Quaternion? = if (hasData(TrackerFrameData.RAW_ROTATION)) {
rawRotation
} else {
null
}
fun tryGetPosition(): Vector3? = if (hasData(TrackerFrameData.POSITION)) {
position
} else {
null
}
fun tryGetAcceleration(): Vector3? = if (hasData(TrackerFrameData.ACCELERATION)) {
acceleration
} else {
null
}
fun hasRotation(): Boolean = hasData(TrackerFrameData.ROTATION)
fun hasPosition(): Boolean = hasData(TrackerFrameData.POSITION)
fun hasAcceleration(): Boolean = hasData(TrackerFrameData.ACCELERATION)
// endregion
companion object {
val empty = TrackerFrame()
fun fromTracker(tracker: Tracker): TrackerFrame? {
// If the tracker is not ready
if (tracker.status != TrackerStatus.OK && tracker.status != TrackerStatus.BUSY && tracker.status != TrackerStatus.OCCLUDED) {
return null
}
val trackerPosition = tracker.trackerPosition
// If tracker has no data at all, there's no point in writing a frame
// Note: This includes rawRotation because of `!tracker.hasRotation`
if (trackerPosition == null && !tracker.hasRotation && !tracker.hasPosition && !tracker.hasAcceleration) {
return null
}
val rotation: Quaternion? = if (tracker.hasRotation) tracker.getRotation() else null
val position: Vector3? = if (tracker.hasPosition) tracker.position else null
val acceleration: Vector3? = if (tracker.hasAcceleration) tracker.getAcceleration() else null
var rawRotation: Quaternion? = if (tracker.hasAdjustedRotation) tracker.getRawRotation() else null
// If the rawRotation is the same as rotation, there's no point in saving it, set it back to null
if (rawRotation == rotation) rawRotation = null
return TrackerFrame(
trackerPosition,
rotation,
position,
acceleration,
rawRotation,
)
}
}
}

View File

@@ -1,23 +0,0 @@
package dev.slimevr.poseframeformat.trackerdata
enum class TrackerFrameData(val id: Int) {
DESIGNATION_STRING(0),
ROTATION(1),
POSITION(2),
TRACKER_POSITION_ENUM(3),
ACCELERATION(4),
RAW_ROTATION(5),
;
val flag: Int = 1 shl id
/*
* Inline is fine for these, there's no negative to inlining them as they'll never
* change, so any warning about it can be safely ignored
*/
inline fun check(dataFlags: Int): Boolean = dataFlags and flag != 0
inline fun add(dataFlags: Int): Int = dataFlags or flag
inline fun remove(dataFlags: Int): Int = dataFlags xor flag
}

View File

@@ -1,51 +0,0 @@
package dev.slimevr.poseframeformat.trackerdata
import dev.slimevr.VRServer
import dev.slimevr.poseframeformat.trackerdata.TrackerFrame.Companion.fromTracker
import dev.slimevr.tracking.trackers.Tracker
import dev.slimevr.tracking.trackers.TrackerStatus
import io.eiren.util.collections.FastList
data class TrackerFrames(var name: String = "", val frames: FastList<TrackerFrame?>) {
constructor(name: String = "", initialCapacity: Int = 5) : this(name, FastList<TrackerFrame?>(initialCapacity))
constructor(baseTracker: Tracker, frames: FastList<TrackerFrame?>) : this(baseTracker.name, frames)
constructor(baseTracker: Tracker, initialCapacity: Int = 5) : this(baseTracker, FastList<TrackerFrame?>(initialCapacity))
fun addFrameFromTracker(index: Int, tracker: Tracker): TrackerFrame? {
val trackerFrame = fromTracker(tracker)
frames.add(index, trackerFrame)
return trackerFrame
}
fun addFrameFromTracker(tracker: Tracker): TrackerFrame? {
val trackerFrame = fromTracker(tracker)
frames.add(trackerFrame)
return trackerFrame
}
fun tryGetFrame(index: Int): TrackerFrame? = if (index < 0 || index >= frames.size) null else frames[index]
fun tryGetFirstNotNullFrame(): TrackerFrame? = frames.firstOrNull { frame -> frame != null }
fun toTracker(): Tracker {
val firstFrame = tryGetFirstNotNullFrame() ?: TrackerFrame.empty
val tracker = Tracker(
device = null,
id = VRServer.getNextLocalTrackerId(),
name = name,
trackerPosition = firstFrame.tryGetTrackerPosition(),
hasPosition = firstFrame.hasPosition(),
hasRotation = firstFrame.hasRotation(),
hasAcceleration = firstFrame.hasAcceleration(),
// Make sure this is false!! Otherwise HumanSkeleton ignores it
isInternal = false,
isComputed = true,
trackRotDirection = false,
)
tracker.status = TrackerStatus.OK
return tracker
}
}

View File

@@ -1,213 +0,0 @@
package dev.slimevr.posestreamer
import com.jme3.math.FastMath
import dev.slimevr.tracking.processor.Bone
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import io.github.axisangles.ktmath.EulerOrder
import io.github.axisangles.ktmath.Quaternion
import org.apache.commons.lang3.StringUtils
import java.io.BufferedWriter
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.io.OutputStreamWriter
class BVHFileStream : PoseDataStream {
var bvhSettings: BVHSettings = BVHSettings.BLENDER
private val writer: BufferedWriter
private var frameCount: Long = 0
private var frameCountOffset: Long = 0
constructor(outputStream: OutputStream, bvhSettings: BVHSettings = BVHSettings.BLENDER) : super(outputStream) {
this.bvhSettings = bvhSettings
writer = BufferedWriter(OutputStreamWriter(outputStream), 4096)
}
constructor(file: File, bvhSettings: BVHSettings = BVHSettings.BLENDER) : super(file) {
this.bvhSettings = bvhSettings
writer = BufferedWriter(OutputStreamWriter(outputStream), 4096)
}
constructor(file: String, bvhSettings: BVHSettings = BVHSettings.BLENDER) : super(file) {
this.bvhSettings = bvhSettings
writer = BufferedWriter(OutputStreamWriter(outputStream), 4096)
}
private fun getBufferedFrameCount(frameCount: Long): String {
val frameString = frameCount.toString()
val bufferCount = LONG_MAX_VALUE_DIGITS - frameString.length
return if (bufferCount > 0) frameString + StringUtils.repeat(' ', bufferCount) else frameString
}
private fun internalNavigateSkeleton(
bone: Bone,
header: (bone: Bone, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) -> Unit,
footer: (distance: Int) -> Unit,
lastBone: Bone? = null,
invertParentRot: Quaternion = Quaternion.IDENTITY,
distance: Int = 0,
isParent: Boolean = false,
) {
val parent = bone.parent
// If we're visiting the parents or at root, continue to the next parent
val visitParent = (isParent || lastBone == null) && parent != null
val children = bone.children
val childCount = children.size - (if (isParent) 1 else 0)
val hasBranch = visitParent || childCount > 0
header(bone, lastBone, invertParentRot, distance, hasBranch, isParent)
if (hasBranch) {
// Cache this inverted rotation to reduce computation for each branch
val thisInvertRot = bone.getGlobalRotation().inv()
if (visitParent) {
internalNavigateSkeleton(parent, header, footer, bone, thisInvertRot, distance + 1, true)
}
for (child in children) {
// If we're a parent, ignore the child
if (isParent && child == lastBone) continue
internalNavigateSkeleton(child, header, footer, bone, thisInvertRot, distance + 1, false)
}
}
footer(distance)
}
private fun navigateSkeleton(
root: Bone,
header: (bone: Bone, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) -> Unit,
footer: (distance: Int) -> Unit = {},
) {
internalNavigateSkeleton(root, header, footer)
}
private fun writeBoneDefHeader(bone: Bone?, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) {
val indentLevel = StringUtils.repeat("\t", distance)
val nextIndentLevel = indentLevel + "\t"
// Handle ends
if (bone == null) {
writer.write("${indentLevel}End Site\n")
} else {
writer
.write("${indentLevel}${if (distance > 0) "JOINT" else "ROOT"} ${bone.boneType}\n")
}
writer.write("$indentLevel{\n")
// Ignore the root and endpoint offsets
if (bone != null && lastBone != null) {
writer.write(
"${nextIndentLevel}OFFSET 0.0 ${(if (isParent) lastBone.length else -lastBone.length) * bvhSettings.offsetScale} 0.0\n",
)
} else {
writer.write("${nextIndentLevel}OFFSET 0.0 0.0 0.0\n")
}
// Define channels
if (bone != null) {
// Only give position for root
if (lastBone != null) {
writer.write("${nextIndentLevel}CHANNELS 3 Zrotation Xrotation Yrotation\n")
} else {
writer.write(
"${nextIndentLevel}CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation\n",
)
}
// Write an empty end bone if there are no branches
// We use null for convenience and treat it as an end node (no bone)
if (!hasBranch) {
val endDistance = distance + 1
writeBoneDefHeader(null, bone, Quaternion.IDENTITY, endDistance, false, false)
writeBoneDefFooter(endDistance)
}
}
}
private fun writeBoneDefFooter(level: Int) {
// Closing bracket
writer.write("${StringUtils.repeat("\t", level)}}\n")
}
private fun writeSkeletonDef(rootBone: Bone) {
navigateSkeleton(rootBone, ::writeBoneDefHeader, ::writeBoneDefFooter)
}
@Throws(IOException::class)
override fun writeHeader(skeleton: HumanSkeleton, streamer: PoseStreamer) {
writer.write("HIERARCHY\n")
writeSkeletonDef(skeleton.getBone(bvhSettings.rootBone))
writer.write("MOTION\n")
writer.write("Frames: ")
// Get frame offset for finishing writing the file
if (outputStream is FileOutputStream) {
// Flush buffer to get proper offset
writer.flush()
frameCountOffset = outputStream.channel.position()
}
writer.write(getBufferedFrameCount(frameCount) + "\n")
// Frame time in seconds
writer.write("Frame Time: ${streamer.frameInterval}\n")
}
private fun writeBoneRot(bone: Bone, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) {
val rot = invertParentRot * bone.getGlobalRotation()
val angles = rot.toEulerAngles(EulerOrder.ZXY)
// Output in order of roll (Z), pitch (X), yaw (Y) (extrinsic)
// Assume spacing is needed at the start (we start with position with no following space)
writer
.write(" ${angles.z * FastMath.RAD_TO_DEG} ${angles.x * FastMath.RAD_TO_DEG} ${angles.y * FastMath.RAD_TO_DEG}")
}
@Throws(IOException::class)
override fun writeFrame(skeleton: HumanSkeleton) {
val rootBone = skeleton.getBone(bvhSettings.rootBone)
val rootPos = rootBone.getPosition()
// Write root position
val positionScale = bvhSettings.positionScale
writer
.write("${rootPos.x * positionScale} ${rootPos.y * positionScale} ${rootPos.z * positionScale}")
navigateSkeleton(rootBone, ::writeBoneRot)
writer.newLine()
frameCount++
}
@Throws(IOException::class)
override fun writeFooter(skeleton: HumanSkeleton) {
// Write the final frame count for files
if (outputStream is FileOutputStream) {
// Flush before anything else
writer.flush()
// Seek to the count offset
outputStream.channel.position(frameCountOffset)
// Overwrite the count with a new value
writer.write(frameCount.toString())
}
}
@Throws(IOException::class)
override fun close() {
writer.close()
super.close()
}
companion object {
private const val LONG_MAX_VALUE_DIGITS = Long.MAX_VALUE.toString().length
}
}

View File

@@ -1,67 +0,0 @@
package dev.slimevr.posestreamer
import dev.slimevr.VRServer
import io.eiren.util.logging.LogManager
import java.io.File
import java.io.IOException
import java.nio.file.Path
class BVHRecorder(server: VRServer) {
private val poseStreamer: ServerPoseStreamer = ServerPoseStreamer(server)
private var poseDataStream: PoseDataStream? = null
val isRecording: Boolean
get() = poseDataStream != null
fun startRecording(path: Path) {
val filePath = path.toFile()
val file = if (filePath.isDirectory()) {
getBvhFile(filePath) ?: return
} else {
filePath
}
try {
val stream = BVHFileStream(file)
poseDataStream = stream
// 100 FPS
poseStreamer.setOutput(stream, 1f / 100f)
} catch (_: IOException) {
LogManager.severe("[BVH] Failed to create the recording file \"${file.path}\".")
}
}
fun endRecording() {
try {
val stream = poseDataStream
if (stream != null) {
poseStreamer.closeOutput(stream)
}
} catch (e1: Exception) {
LogManager.severe("[BVH] Exception while closing poseDataStream", e1)
} finally {
poseDataStream = null
}
}
private fun getBvhFile(bvhSaveDir: File): File? {
if (bvhSaveDir.isDirectory() || bvhSaveDir.mkdirs()) {
var saveRecording: File?
var recordingIndex = 1
do {
saveRecording =
File(bvhSaveDir, "BVH-Recording${recordingIndex++}.bvh")
} while (saveRecording.exists())
return saveRecording
} else {
LogManager
.severe(
"[BVH] Failed to create the recording directory \"${bvhSaveDir.path}\".",
)
}
return null
}
}

View File

@@ -1,41 +0,0 @@
package dev.slimevr.posestreamer
import dev.slimevr.tracking.processor.BoneType
class BVHSettings {
var offsetScale: Float = 100f
private set
var positionScale: Float = 100f
private set
var rootBone: BoneType = BoneType.HIP
private set
constructor()
constructor(source: BVHSettings) {
this.offsetScale = source.offsetScale
this.positionScale = source.positionScale
}
fun setOffsetScale(offsetScale: Float): BVHSettings {
this.offsetScale = offsetScale
return this
}
fun setPositionScale(positionScale: Float): BVHSettings {
this.positionScale = positionScale
return this
}
fun setRootBone(rootBone: BoneType): BVHSettings {
this.rootBone = rootBone
return this
}
companion object {
val DEFAULT: BVHSettings = BVHSettings()
val BLENDER: BVHSettings = BVHSettings(DEFAULT)
.setOffsetScale(1f)
.setPositionScale(1f)
}
}

View File

@@ -1,33 +0,0 @@
package dev.slimevr.posestreamer
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.OutputStream
import java.lang.AutoCloseable
abstract class PoseDataStream protected constructor(protected val outputStream: OutputStream) : AutoCloseable {
var isClosed: Boolean = false
protected set
protected constructor(file: File) : this(FileOutputStream(file))
protected constructor(file: String) : this(FileOutputStream(file))
@Throws(IOException::class)
open fun writeHeader(skeleton: HumanSkeleton, streamer: PoseStreamer) {
}
@Throws(IOException::class)
abstract fun writeFrame(skeleton: HumanSkeleton)
@Throws(IOException::class)
open fun writeFooter(skeleton: HumanSkeleton) {
}
@Throws(IOException::class)
override fun close() {
outputStream.close()
this.isClosed = true
}
}

View File

@@ -1,35 +0,0 @@
package dev.slimevr.posestreamer
import dev.slimevr.poseframeformat.PfrIO.readFromFile
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.poseframeformat.player.TrackerFramesPlayer
import dev.slimevr.tracking.processor.HumanPoseManager
import java.io.File
class PoseFrameStreamer : PoseStreamer {
val player: TrackerFramesPlayer
val hpm: HumanPoseManager
private constructor(
player: TrackerFramesPlayer,
hpm: HumanPoseManager,
) : super(hpm.skeleton) {
this.player = player
this.hpm = hpm
}
constructor(player: TrackerFramesPlayer) : this(player, HumanPoseManager(player.trackers.toList()))
constructor(poseFrames: PoseFrames) : this(TrackerFramesPlayer(poseFrames))
constructor(file: File) : this(readFromFile(file))
constructor(path: String) : this(File(path))
@Synchronized
fun streamAllFrames() {
for (i in 0 until player.maxFrameCount) {
player.setCursors(i)
hpm.update()
captureFrame()
}
}
}

View File

@@ -1,74 +0,0 @@
package dev.slimevr.posestreamer
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import io.eiren.util.logging.LogManager
import java.io.IOException
open class PoseStreamer(skeleton: HumanSkeleton) {
// 60 FPS
private var intervalInternal: Float = 1f / 60f
private var stream: PoseDataStream? = null
var skeleton: HumanSkeleton = skeleton
protected set
@Synchronized
fun captureFrame() {
// Make sure the stream is open before trying to write
val stream = stream
if (stream == null || stream.isClosed) {
return
}
try {
stream.writeFrame(skeleton)
} catch (e: Exception) {
// Handle any exceptions without crashing the program
LogManager.severe("[PoseStreamer] Exception while saving frame", e)
}
}
open var frameInterval: Float
get() = intervalInternal
set(interval) {
require(interval > 0f) { "interval must be a value greater than 0" }
this.intervalInternal = interval
}
@Synchronized
@Throws(IOException::class)
fun setOutput(poseFileStream: PoseDataStream, interval: Float) {
this.frameInterval = interval
this.output = poseFileStream
}
@set:Throws(IOException::class)
@set:Synchronized
open var output: PoseDataStream?
get() = stream
set(stream) {
requireNotNull(stream) { "stream must not be null" }
stream.writeHeader(skeleton, this)
this.stream = stream
}
val hasOutput
get() = output?.isClosed == false
@Synchronized
@Throws(IOException::class)
fun closeOutput() {
val stream = this.stream
if (stream != null) {
closeOutput(stream)
this.stream = null
}
}
@Synchronized
@Throws(IOException::class)
fun closeOutput(stream: PoseDataStream) {
stream.writeFooter(skeleton)
stream.close()
}
}

View File

@@ -1,30 +0,0 @@
package dev.slimevr.posestreamer
import dev.slimevr.VRServer
import dev.slimevr.tracking.processor.skeleton.HumanSkeleton
import dev.slimevr.util.ann.VRServerThread
class ServerPoseStreamer(val server: VRServer) : TickPoseStreamer(server.humanPoseManager.skeleton) {
init {
// Register callbacks/events
server.addSkeletonUpdatedCallback { skeleton: HumanSkeleton? ->
this.onSkeletonUpdated(
skeleton,
)
}
server.addOnTick { this.tick() }
}
@VRServerThread
fun onSkeletonUpdated(skeleton: HumanSkeleton?) {
if (skeleton != null) {
this.skeleton = skeleton
}
}
@VRServerThread
fun tick() {
super.tick(server.fpsTimer.timePerFrame)
}
}

Some files were not shown because too many files have changed in this diff Show More