diff --git a/.github/workflows/build-gui.yml b/.github/workflows/build-gui.yml
index 11aa25aed0..9b8ba45738 100644
--- a/.github/workflows/build-gui.yml
+++ b/.github/workflows/build-gui.yml
@@ -57,7 +57,7 @@ jobs:
- if: matrix.os == 'ubuntu-22.04'
name: Set up Linux dependencies
- uses: awalsh128/cache-apt-pkgs-action@latest
+ uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: libgtk-3-dev webkit2gtk-4.1 libappindicator3-dev librsvg2-dev patchelf
# Increment to invalidate the cache
diff --git a/.github/workflows/gradle.yaml b/.github/workflows/gradle.yaml
index 9d1b711612..3c393d3c6f 100644
--- a/.github/workflows/gradle.yaml
+++ b/.github/workflows/gradle.yaml
@@ -158,7 +158,7 @@ jobs:
path: server/desktop/build/libs/
- name: Set up Linux dependencies
- uses: awalsh128/cache-apt-pkgs-action@latest
+ uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: |
build-essential curl wget file libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev
diff --git a/flake.nix b/flake.nix
index 2bdecbac9d..d33070ad51 100644
--- a/flake.nix
+++ b/flake.nix
@@ -140,7 +140,7 @@
enterShell = with pkgs; ''
# Export a LD_LIBRARY_PATH without libudev-zero as libgudev not likey
export SLIMEVR_RUST_LD_LIBRARY_PATH="$LD_LIBRARY_PATH"
- export LD_LIBRARY_PATH="${libudev-zero}/lib:$LD_LIBRARY_PATH"
+ export LD_LIBRARY_PATH="${libudev-zero}/lib:${libayatana-appindicator}/lib:$LD_LIBRARY_PATH"
# GStreamer plugins won't be found without this
export GST_PLUGIN_SYSTEM_PATH_1_0="${pkgs.gst_all_1.gstreamer.out}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-base}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-good}/lib/gstreamer-1.0:${pkgs.gst_all_1.gst-plugins-bad}/lib/gstreamer-1.0"
'';
diff --git a/gradle.properties b/gradle.properties
index 613dd48626..da78dfd873 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -14,7 +14,7 @@ android.nonTransitiveRClass=true
org.gradle.unsafe.configuration-cache=false
kotlinVersion=2.0.20
-spotlessVersion=6.25.0
+spotlessVersion=7.0.2
shadowJarVersion=8.3.2
buildconfigVersion=5.5.0
grgitVersion=5.2.2
diff --git a/gui/package.json b/gui/package.json
index 00bd7da5ca..00614168b5 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -55,7 +55,8 @@
"preview-vite": "vite preview",
"javaversion-build": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class",
"gen:javaversion": "cd src-tauri/src/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class",
- "gen:firmware-tool": "openapi-codegen gen firmwareTool"
+ "gen:firmware-tool": "openapi-codegen gen firmwareTool",
+ "gen:icons": "tauri icon --ios-color '#663499' src-tauri/icons/icon.svg"
},
"devDependencies": {
"@dword-design/eslint-plugin-import-alias": "^4.0.9",
@@ -93,4 +94,4 @@
"vite": "^5.4.8",
"typescript-eslint": "^8.8.0"
}
-}
\ No newline at end of file
+}
diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl
index b9a1816a34..ac9a714a39 100644
--- a/gui/public/i18n/en/translation.ftl
+++ b/gui/public/i18n/en/translation.ftl
@@ -122,6 +122,10 @@ reset-reset_all_warning =
Are you sure you want to do this?
reset-reset_all_warning-reset = Reset proportions
reset-reset_all_warning-cancel = Cancel
+reset-reset_all_warning_default =
+ Warning: You currently don't have your height defined, which
+ will make the proportions be based on a default height.
+ Are you sure you want to do this?
reset-full = Full Reset
reset-mounting = Reset Mounting
@@ -446,6 +450,11 @@ settings-general-fk_settings-leg_tweak-foot_plant-description = Foot-plant rotat
settings-general-fk_settings-leg_fk = Leg tracking
settings-general-fk_settings-leg_fk-reset_mounting_feet-description = Enable feet Mounting Reset by tiptoeing.
settings-general-fk_settings-leg_fk-reset_mounting_feet = Feet Mounting Reset
+settings-general-fk_settings-enforce_joint_constraints = Skeletal Limits
+settings-general-fk_settings-enforce_joint_constraints-enforce_constraints = Enforce constraints
+settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description = Prevents joints from rotating past their limit
+settings-general-fk_settings-enforce_joint_constraints-correct_constraints = Correct with constraints
+settings-general-fk_settings-enforce_joint_constraints-correct_constraints-description = Correct joint rotations when they push past their limit
settings-general-fk_settings-arm_fk = Arm tracking
settings-general-fk_settings-arm_fk-description = Force arms to be tracked from the headset (HMD) even if positional hand data is available.
settings-general-fk_settings-arm_fk-force_arms = Force arms from HMD
@@ -962,6 +971,15 @@ onboarding-choose_proportions-manual_proportions = Manual proportions
# Italicized text
onboarding-choose_proportions-manual_proportions-subtitle = For small touches
onboarding-choose_proportions-manual_proportions-description = This will let you adjust your proportions manually by modifying them directly
+onboarding-choose_proportions-scaled_proportions = Scaled proportions
+# Italized text
+onboarding-choose_proportions-scaled_proportions-subtitle = Recommended for new users
+# Multiline string
+onboarding-choose_proportions-scaled_proportions-description =
+ This will scale the proportions of an average human body based on your height, this will help for basic full-body tracking.
+
+ This requires having your headset (HMD) connected to SlimeVR and on your head!
+onboarding-choose_proportions-scaled_proportions-button = Scaled proportions
onboarding-choose_proportions-export = Export proportions
onboarding-choose_proportions-import = Import proportions
onboarding-choose_proportions-import-success = Imported
@@ -981,9 +999,11 @@ onboarding-automatic_proportions-title = Measure your body
onboarding-automatic_proportions-description = For SlimeVR trackers to work, we need to know the length of your bones. This short calibration will measure it for you.
onboarding-automatic_proportions-manual = Manual proportions
onboarding-automatic_proportions-prev_step = Previous step
+
onboarding-automatic_proportions-put_trackers_on-title = Put on your trackers
onboarding-automatic_proportions-put_trackers_on-description = To calibrate your proportions, we're gonna use the trackers you just assigned. Put on all your trackers, you can see which are which in the figure to the right.
onboarding-automatic_proportions-put_trackers_on-next = I have all my trackers on
+
onboarding-automatic_proportions-requirements-title = Requirements
# Each line of text is a different list item
onboarding-automatic_proportions-requirements-descriptionv2 =
@@ -993,23 +1013,38 @@ onboarding-automatic_proportions-requirements-descriptionv2 =
Your headset is reporting positional data to the SlimeVR server (this generally means having SteamVR running and connected to SlimeVR using SlimeVR's SteamVR driver).
Your tracking is working and is accurately representing your movements (ex. you have performed a full reset and they move the right direction when kicking, bending over, sitting, etc).
onboarding-automatic_proportions-requirements-next = I have read the requirements
-onboarding-automatic_proportions-check_height-title = Check your height
-onboarding-automatic_proportions-check_height-description = We use your height as a basis of our measurements by using the headset's (HMD) height as an approximation of your actual height, but it's better to check if they are right yourself!
+
+onboarding-automatic_proportions-check_height-title-v2 = Measure your height
+onboarding-automatic_proportions-check_height-description-v2 = Your headset (HMD) height should be slightly less than your full height, as headsets measure your eye height. This measurement will be used as a baseline for your body proportions.
# All the text is in bold!
-onboarding-automatic_proportions-check_height-calculation_warning = Please press the button while standing upright to calculate your height. You have 3 seconds after you press the button!
+onboarding-automatic_proportions-check_height-calculation_warning-v2 = Start measuring while standing upright to calculate your height. Be careful to not raise your hands higher than your headset, as they may affect the measurement!
onboarding-automatic_proportions-check_height-guardian_tip = If you are using a standalone VR headset, make sure to have your guardian /
boundary turned on so that your height is correct!
-onboarding-automatic_proportions-check_height-fetch_height = I'm standing!
# Context is that the height is unknown
onboarding-automatic_proportions-check_height-unknown = Unknown
# Shows an element below it
-onboarding-automatic_proportions-check_height-hmd_height1 = Your HMD height is
+onboarding-automatic_proportions-check_height-hmd_height2 = Your headset height is:
+onboarding-automatic_proportions-check_height-measure-start = Start measuring
+onboarding-automatic_proportions-check_height-measure-stop = Stop measuring
+onboarding-automatic_proportions-check_height-measure-reset = Retry measuring
+onboarding-automatic_proportions-check_height-next_step = Use headset height
+
+onboarding-automatic_proportions-check_floor_height-title = Measure your floor height (optional)
+onboarding-automatic_proportions-check_floor_height-description = In some cases, your floor height may not be set correctly by your headset, causing the headset height to be measured as higher than it should be. You can measure the "height" of your floor to correct your headset height.
+# All the text is in bold!
+onboarding-automatic_proportions-check_floor_height-calculation_warning = If you are sure that your floor height is correct, you can skip this step.
# Shows an element below it
-onboarding-automatic_proportions-check_height-height1 = so your actual height is
-onboarding-automatic_proportions-check_height-next_step = They are fine
+onboarding-automatic_proportions-check_floor_height-floor_height = Your floor height is:
+onboarding-automatic_proportions-check_floor_height-measure-start = Start measuring
+onboarding-automatic_proportions-check_floor_height-measure-stop = Stop measuring
+onboarding-automatic_proportions-check_floor_height-measure-reset = Retry measuring
+onboarding-automatic_proportions-check_floor_height-skip_step = Skip step and save
+onboarding-automatic_proportions-check_floor_height-next_step = Use floor height and save
+
onboarding-automatic_proportions-start_recording-title = Get ready to move
onboarding-automatic_proportions-start_recording-description = We're now going to record some specific poses and moves. These will be prompted in the next screen. Be ready to start when the button is pressed!
onboarding-automatic_proportions-start_recording-next = Start Recording
+
onboarding-automatic_proportions-recording-title = REC
onboarding-automatic_proportions-recording-description-p0 = Recording in progress...
onboarding-automatic_proportions-recording-description-p1 = Make the moves shown below:
@@ -1027,12 +1062,14 @@ onboarding-automatic_proportions-recording-timer = { $time ->
[one] 1 second left
*[other] { $time } seconds left
}
+
onboarding-automatic_proportions-verify_results-title = Verify results
onboarding-automatic_proportions-verify_results-description = Check the results below, do they look correct?
onboarding-automatic_proportions-verify_results-results = Recording results
onboarding-automatic_proportions-verify_results-processing = Processing the result
onboarding-automatic_proportions-verify_results-redo = Redo recording
onboarding-automatic_proportions-verify_results-confirm = They're correct
+
onboarding-automatic_proportions-done-title = Body measured and saved.
onboarding-automatic_proportions-done-description = Your body proportions' calibration is complete!
onboarding-automatic_proportions-error_modal-v2 =
@@ -1041,6 +1078,26 @@ onboarding-automatic_proportions-error_modal-v2 =
Please check the docs or join our Discord for help ^_^
onboarding-automatic_proportions-error_modal-confirm = Understood!
+onboarding-automatic_proportions-smol_warning =
+ Your configured height of { $height } is smaller than the minimum accepted height of { $minHeight }.
+ Please redo the measurements and ensure they are correct.
+onboarding-automatic_proportions-smol_warning-cancel = Go back
+
+## Tracker scaled proportions setup
+onboarding-scaled_proportions-title = Scaled proportions
+onboarding-scaled_proportions-description = For SlimeVR trackers to work, we need to know the length of your bones. This will use an average proportion and scale it based on your height.
+onboarding-scaled_proportions-manual_height-title = Configure your height
+onboarding-scaled_proportions-manual_height-description = Your headset (HMD) height should be slightly less than your full height, as headsets measure your eye height. This height will be used as a baseline for your body proportions.
+onboarding-scaled_proportions-manual_height-missing_steamvr = SteamVR is not currently connected to SlimeVR, so measurements can't be based on your headset. Proceed at your own risk or check the docs!
+onboarding-scaled_proportions-manual_height-height = Your headset height is
+onboarding-scaled_proportions-manual_height-next_step = Continue and save
+
+## Tracker scaled proportions reset
+onboarding-scaled_proportions-reset_proportion-title = Reset your body proportions
+onboarding-scaled_proportions-reset_proportion-description = To set your body proportions based on your height, you need to now reset all of your proportions. This will clear any proportions you have configured and provide a baseline configuration.
+onboarding-scaled_proportions-done-title = Body proportions set
+onboarding-scaled_proportions-done-description = Your body proportions should now be configured based on your height.
+
## Home
home-no_trackers = No trackers detected or assigned
diff --git a/gui/src-tauri/capabilities/migrated.json b/gui/src-tauri/capabilities/migrated.json
index 5e8916e035..c284614949 100644
--- a/gui/src-tauri/capabilities/migrated.json
+++ b/gui/src-tauri/capabilities/migrated.json
@@ -6,14 +6,7 @@
"main"
],
"permissions": [
- "core:path:default",
- "core:event:default",
- "core:window:default",
- "core:app:default",
- "core:resources:default",
- "core:menu:default",
- "core:tray:default",
- "core:webview:default",
+ "core:default",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-minimize",
@@ -21,6 +14,8 @@
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
+ "core:window:allow-destroy",
+ "core:window:allow-request-user-attention",
"core:window:allow-set-decorations",
"store:default",
"os:allow-os-type",
diff --git a/gui/src-tauri/icons/1024x1024.png b/gui/src-tauri/icons/1024x1024.png
deleted file mode 100644
index 1e96b691c0..0000000000
Binary files a/gui/src-tauri/icons/1024x1024.png and /dev/null differ
diff --git a/gui/src-tauri/icons/128x128.png b/gui/src-tauri/icons/128x128.png
index 998e8fee3b..8138d275f8 100644
Binary files a/gui/src-tauri/icons/128x128.png and b/gui/src-tauri/icons/128x128.png differ
diff --git a/gui/src-tauri/icons/128x128@2x.png b/gui/src-tauri/icons/128x128@2x.png
index 063fd21a7d..2c56f71b08 100644
Binary files a/gui/src-tauri/icons/128x128@2x.png and b/gui/src-tauri/icons/128x128@2x.png differ
diff --git a/gui/src-tauri/icons/32x32.png b/gui/src-tauri/icons/32x32.png
index 4040b1a4ee..6441457164 100644
Binary files a/gui/src-tauri/icons/32x32.png and b/gui/src-tauri/icons/32x32.png differ
diff --git a/gui/src-tauri/icons/Square107x107Logo.png b/gui/src-tauri/icons/Square107x107Logo.png
index de0a69ce53..ac7f760794 100644
Binary files a/gui/src-tauri/icons/Square107x107Logo.png and b/gui/src-tauri/icons/Square107x107Logo.png differ
diff --git a/gui/src-tauri/icons/Square142x142Logo.png b/gui/src-tauri/icons/Square142x142Logo.png
index 63bacc8c3f..28e0d6d27e 100644
Binary files a/gui/src-tauri/icons/Square142x142Logo.png and b/gui/src-tauri/icons/Square142x142Logo.png differ
diff --git a/gui/src-tauri/icons/Square150x150Logo.png b/gui/src-tauri/icons/Square150x150Logo.png
index 3f2b7c291e..c9202779ee 100644
Binary files a/gui/src-tauri/icons/Square150x150Logo.png and b/gui/src-tauri/icons/Square150x150Logo.png differ
diff --git a/gui/src-tauri/icons/Square284x284Logo.png b/gui/src-tauri/icons/Square284x284Logo.png
index ec06bdb994..7c35ed105e 100644
Binary files a/gui/src-tauri/icons/Square284x284Logo.png and b/gui/src-tauri/icons/Square284x284Logo.png differ
diff --git a/gui/src-tauri/icons/Square30x30Logo.png b/gui/src-tauri/icons/Square30x30Logo.png
index dc06462109..6f55246a20 100644
Binary files a/gui/src-tauri/icons/Square30x30Logo.png and b/gui/src-tauri/icons/Square30x30Logo.png differ
diff --git a/gui/src-tauri/icons/Square310x310Logo.png b/gui/src-tauri/icons/Square310x310Logo.png
index d91e8646e5..d8f504bb1b 100644
Binary files a/gui/src-tauri/icons/Square310x310Logo.png and b/gui/src-tauri/icons/Square310x310Logo.png differ
diff --git a/gui/src-tauri/icons/Square44x44Logo.png b/gui/src-tauri/icons/Square44x44Logo.png
index 58c4a9dd36..9131b49b95 100644
Binary files a/gui/src-tauri/icons/Square44x44Logo.png and b/gui/src-tauri/icons/Square44x44Logo.png differ
diff --git a/gui/src-tauri/icons/Square71x71Logo.png b/gui/src-tauri/icons/Square71x71Logo.png
index 35f5c23299..15965edc86 100644
Binary files a/gui/src-tauri/icons/Square71x71Logo.png and b/gui/src-tauri/icons/Square71x71Logo.png differ
diff --git a/gui/src-tauri/icons/Square89x89Logo.png b/gui/src-tauri/icons/Square89x89Logo.png
index 9b2c5fbefc..505fadf3ff 100644
Binary files a/gui/src-tauri/icons/Square89x89Logo.png and b/gui/src-tauri/icons/Square89x89Logo.png differ
diff --git a/gui/src-tauri/icons/StoreLogo.png b/gui/src-tauri/icons/StoreLogo.png
index 1666958da1..de74c7a55b 100644
Binary files a/gui/src-tauri/icons/StoreLogo.png and b/gui/src-tauri/icons/StoreLogo.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
index b7c6f3f355..3977b79719 100644
Binary files a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
index 16e92e0032..02819a9234 100644
Binary files a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
index b7c6f3f355..3977b79719 100644
Binary files a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
index f9e1acb687..30bd847b19 100644
Binary files a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
index af44353027..ea3fa0e0cf 100644
Binary files a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
index f9e1acb687..30bd847b19 100644
Binary files a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
index 4427bbc05d..61b2afdbf1 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
index 69dea92b82..52f07b9041 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
index 4427bbc05d..61b2afdbf1 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
index 0a9b2a493b..ebad2842fd 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
index a4bb780f6b..fb1cf7f6a4 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
index 0a9b2a493b..ebad2842fd 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
index b8b6f29d4c..1ac9089da5 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index f23cc64173..09e6959fc4 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
index b8b6f29d4c..1ac9089da5 100644
Binary files a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/gui/src-tauri/icons/icon.icns b/gui/src-tauri/icons/icon.icns
index 5e4c8bf25f..753e44c34c 100644
Binary files a/gui/src-tauri/icons/icon.icns and b/gui/src-tauri/icons/icon.icns differ
diff --git a/gui/src-tauri/icons/icon.ico b/gui/src-tauri/icons/icon.ico
index 8083c3fe40..9e88b20ddc 100644
Binary files a/gui/src-tauri/icons/icon.ico and b/gui/src-tauri/icons/icon.ico differ
diff --git a/gui/src-tauri/icons/icon.png b/gui/src-tauri/icons/icon.png
index 4ffce3d382..6a09da7262 100644
Binary files a/gui/src-tauri/icons/icon.png and b/gui/src-tauri/icons/icon.png differ
diff --git a/gui/src-tauri/icons/icon.svg b/gui/src-tauri/icons/icon.svg
index 2359e91779..1917a06da8 100644
--- a/gui/src-tauri/icons/icon.svg
+++ b/gui/src-tauri/icons/icon.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png b/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png
index 0698d80651..383c253527 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
index d044f311c6..28f720c69a 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png b/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png
index d044f311c6..28f720c69a 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png b/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png
index 2de6cb8fa9..72267593bb 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png b/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png
index ecb2a4465c..3582cf6ba7 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
index 0c29dc64bf..26a9a81efb 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png b/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png
index 0c29dc64bf..26a9a81efb 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png b/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png
index 5390787497..0aa9c6842c 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png b/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png
index d044f311c6..28f720c69a 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
index 9d9eb38f04..9f3286ca2f 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png b/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png
index 9d9eb38f04..9f3286ca2f 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png b/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png
index 65aecece71..21f126cf16 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-512@2x.png b/gui/src-tauri/icons/ios/AppIcon-512@2x.png
index 47174da008..204f8fa30a 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-512@2x.png and b/gui/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png b/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png
index 65aecece71..21f126cf16 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png b/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png
index c5d2ce2003..a8390e3161 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png b/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png
index ef71ccf16f..5486ec449b 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png b/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png
index 44c3b3b8d0..84497bb0a6 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
index 54d6e03609..c20b91316a 100644
Binary files a/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs
index fda7f594a1..f40da30ab0 100644
--- a/gui/src-tauri/src/main.rs
+++ b/gui/src-tauri/src/main.rs
@@ -13,7 +13,8 @@ use clap::Parser;
use color_eyre::Result;
use state::WindowState;
use tauri::Emitter;
-use tauri::{Manager, RunEvent, WindowEvent};
+use tauri::WindowEvent;
+use tauri::{Manager, RunEvent};
use tauri_plugin_shell::process::CommandChild;
use crate::util::{
diff --git a/gui/src/App.tsx b/gui/src/App.tsx
index e2bb4c4a6f..0281f35fac 100644
--- a/gui/src/App.tsx
+++ b/gui/src/App.tsx
@@ -56,6 +56,7 @@ import { AppLayout } from './AppLayout';
import { Preload } from './components/Preload';
import { UnknownDeviceModal } from './components/UnknownDeviceModal';
import { useDiscordPresence } from './hooks/discord-presence';
+import { ScaledProportionsPage } from './components/onboarding/pages/body-proportions/ScaledProportions';
import { EmptyLayout } from './components/EmptyLayout';
import { AdvancedSettings } from './components/settings/pages/AdvancedSettings';
import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate';
@@ -162,6 +163,10 @@ function Layout() {
path="body-proportions/manual"
element={ }
/>
+ }
+ />
} />
}>
diff --git a/gui/src/components/TopBar.tsx b/gui/src/components/TopBar.tsx
index 4e5f76f44d..c9495a4d6c 100644
--- a/gui/src/components/TopBar.tsx
+++ b/gui/src/components/TopBar.tsx
@@ -1,4 +1,3 @@
-import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { ReactNode, useContext, useEffect, useState } from 'react';
import { NavLink, useMatch } from 'react-router-dom';
import {
@@ -25,11 +24,16 @@ import { invoke } from '@tauri-apps/api/core';
import { useTrackers } from '@/hooks/tracker';
import { TrackersStillOnModal } from './TrackersStillOnModal';
import { useConfig } from '@/hooks/config';
-import { listen } from '@tauri-apps/api/event';
+import { listen, TauriEvent } from '@tauri-apps/api/event';
import { TrayOrExitModal } from './TrayOrExitModal';
import { error } from '@/utils/logging';
import { useDoubleTap } from 'use-double-tap';
import { isTrayAvailable } from '@/utils/tauri';
+import {
+ CloseRequestedEvent,
+ getCurrentWindow,
+ UserAttentionType,
+} from '@tauri-apps/api/window';
export function VersionTag() {
return (
@@ -70,10 +74,11 @@ export function TopBar({
const doesMatchSettings = useMatch({
path: '/settings/*',
});
+
const closeApp = async () => {
await saveConfig();
await invoke('update_window_state');
- await getCurrentWebviewWindow().close();
+ await getCurrentWindow().destroy();
};
const tryCloseApp = async (dontTray = false) => {
if (isTrayAvailable && config?.useTray === null) {
@@ -81,8 +86,8 @@ export function TopBar({
return;
}
- if (config?.useTray && !dontTray) {
- await getCurrentWebviewWindow().hide();
+ if (isTrayAvailable && config?.useTray && !dontTray) {
+ await getCurrentWindow().hide();
await invoke('update_tray_text');
} else if (
config?.connectedTrackersWarning &&
@@ -95,25 +100,42 @@ export function TopBar({
await closeApp();
}
};
+
const showVersionBind = useDoubleTap(() => setShowVersionMobile(true));
const unshowVersionBind = useDoubleTap(() => setShowVersionMobile(false));
useEffect(() => {
- const unlisten = listen('try-close', async () => {
- const window = getCurrentWebviewWindow();
+ const unlistenTrayClose = listen('try-close', async () => {
+ const window = getCurrentWindow();
await window.show();
+ await window.requestUserAttention(UserAttentionType.Critical);
await window.setFocus();
if (isTrayAvailable) await invoke('update_tray_text');
await tryCloseApp(true);
});
+
+ const unlistenCloseRequested = getCurrentWindow().listen(
+ TauriEvent.WINDOW_CLOSE_REQUESTED,
+ async (data) => {
+ const ev = new CloseRequestedEvent(data);
+ ev.preventDefault();
+ await tryCloseApp();
+ }
+ );
+
return () => {
- unlisten.then((fn) => fn());
+ unlistenTrayClose.then((fn) => fn());
+ unlistenCloseRequested.then((fn) => fn());
};
- }, [config?.useTray, config?.connectedTrackersWarning]);
+ }, [
+ config?.useTray,
+ config?.connectedTrackersWarning,
+ JSON.stringify(connectedIMUTrackers.map((t) => t.tracker.status)),
+ ]);
useEffect(() => {
if (config === null || !isTauri) return;
- getCurrentWebviewWindow().setDecorations(config?.decorations).catch(error);
+ getCurrentWindow().setDecorations(config?.decorations).catch(error);
}, [config?.decorations]);
useEffect(() => {
@@ -252,13 +274,13 @@ export function TopBar({
<>
getCurrentWebviewWindow().minimize()}
+ onClick={() => getCurrentWindow().minimize()}
>
getCurrentWebviewWindow().toggleMaximize()}
+ onClick={() => getCurrentWindow().toggleMaximize()}
>
@@ -286,7 +308,7 @@ export function TopBar({
// Doing this in here just in case config doesn't get updated in time
if (useTray) {
- await getCurrentWebviewWindow().hide();
+ await getCurrentWindow().hide();
await invoke('update_tray_text');
} else if (
config?.connectedTrackersWarning &&
@@ -304,7 +326,10 @@ export function TopBar({
closeApp()}
- cancel={() => setConnectedTrackerWarning(false)}
+ cancel={() => {
+ setConnectedTrackerWarning(false);
+ getCurrentWindow().requestUserAttention(null);
+ }}
>
>
);
diff --git a/gui/src/components/commons/NumberSelector.tsx b/gui/src/components/commons/NumberSelector.tsx
index d2a2da7891..a21d35634a 100644
--- a/gui/src/components/commons/NumberSelector.tsx
+++ b/gui/src/components/commons/NumberSelector.tsx
@@ -1,6 +1,8 @@
import { Control, Controller } from 'react-hook-form';
import { Button } from './Button';
import { Typography } from './Typography';
+import { useCallback, useMemo } from 'react';
+import { useLocaleConfig } from '@/i18n/config';
export function NumberSelector({
label,
@@ -10,7 +12,9 @@ export function NumberSelector({
min,
max,
step,
+ doubleStep,
disabled = false,
+ showButtonWithNumber = false,
}: {
label?: string;
valueLabelFormat?: (value: number) => string;
@@ -19,14 +23,36 @@ export function NumberSelector({
min: number;
max: number;
step: number | ((value: number, add: boolean) => number);
+ doubleStep?: number;
disabled?: boolean;
+ showButtonWithNumber?: boolean;
}) {
+ const { currentLocales } = useLocaleConfig();
+
const stepFn =
typeof step === 'function'
? step
: (value: number, add: boolean) =>
+(add ? value + step : value - step).toFixed(2);
+ const doubleStepFn = useCallback(
+ (value: number, add: boolean) =>
+ doubleStep === undefined
+ ? 0
+ : +(add ? value + doubleStep : value - doubleStep).toFixed(2),
+ [doubleStep]
+ );
+
+ const decimalFormat = useMemo(
+ () =>
+ new Intl.NumberFormat(currentLocales, {
+ style: 'decimal',
+ maximumFractionDigits: 2,
+ signDisplay: 'exceptZero',
+ }),
+ [currentLocales]
+ );
+
return (
{label}
-
+
+ {doubleStep !== undefined && (
+ onChange(doubleStepFn(value, false))}
+ disabled={doubleStepFn(value, false) < min || disabled}
+ >
+ {showButtonWithNumber
+ ? decimalFormat.format(-doubleStep)
+ : '--'}
+
+ )}
{valueLabelFormat ? valueLabelFormat(value) : value}
-
+
+
+ {doubleStep !== undefined && (
+ onChange(doubleStepFn(value, true))}
+ disabled={doubleStepFn(value, true) > max || disabled}
+ >
+ {showButtonWithNumber
+ ? decimalFormat.format(doubleStep)
+ : '++'}
+
+ )}
diff --git a/gui/src/components/onboarding/StepperSlider.tsx b/gui/src/components/onboarding/StepperSlider.tsx
index 8778bfc58b..3f5815af88 100644
--- a/gui/src/components/onboarding/StepperSlider.tsx
+++ b/gui/src/components/onboarding/StepperSlider.tsx
@@ -94,30 +94,42 @@ export function StepDot({
export function StepperSlider({
variant,
steps,
+ back,
+ forward,
}: {
variant: 'alone' | 'onboarding';
steps: Step[];
+ /**
+ * Ran when step is 0 and `prevStep` is executed
+ */
+ back?: () => void;
+ /**
+ * Ran when step is `steps.length - 1` and nextStep is executed
+ */
+ forward?: () => void;
}) {
const ref = useRef
(null);
const { width } = useElemSize(ref);
- const [stepsContainers, setSteps] = useState(0);
const [shouldAnimate, setShouldAnimate] = useState(true);
const [step, setStep] = useState(0);
useEffect(() => {
- if (!ref.current) return;
- const stepsContainers =
- ref.current.getElementsByClassName('step-container');
- setSteps(stepsContainers.length);
- }, [ref]);
+ setStep((x) => Math.min(x, steps.length - 1));
+ }, [steps.length]);
const nextStep = () => {
- if (step + 1 === stepsContainers) return;
+ if (step + 1 === steps.length) {
+ forward?.();
+ return;
+ }
setStep(step + 1);
};
const prevStep = () => {
- if (step - 1 < 0) return;
+ if (step - 1 < 0) {
+ back?.();
+ return;
+ }
setStep(step - 1);
};
@@ -168,7 +180,7 @@ export function StepperSlider({
- {Array.from({ length: stepsContainers }).map((_, index) => (
+ {Array.from({ length: steps.length }).map((_, index) => (
{index !== 0 && (
diff --git a/gui/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx
index b843ba1352..f0527ed82b 100644
--- a/gui/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx
+++ b/gui/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx
@@ -1,9 +1,6 @@
import { useLocalization } from '@fluent/react';
-import { RpcMessage, SkeletonResetAllRequestT } from 'solarxr-protocol';
import { AutoboneContextC, useProvideAutobone } from '@/hooks/autobone';
import { useOnboarding } from '@/hooks/onboarding';
-import { useWebsocketAPI } from '@/hooks/websocket-api';
-import { Button } from '@/components/commons/Button';
import { Typography } from '@/components/commons/Typography';
import { StepperSlider } from '@/components/onboarding/StepperSlider';
import { DoneStep } from './autobone-steps/Done';
@@ -12,83 +9,55 @@ import { PutTrackersOnStep } from './autobone-steps/PutTrackersOn';
import { Recording } from './autobone-steps/Recording';
import { StartRecording } from './autobone-steps/StartRecording';
import { VerifyResultsStep } from './autobone-steps/VerifyResults';
-import { useCountdown } from '@/hooks/countdown';
-import { CheckHeight } from './autobone-steps/CheckHeight';
+import { CheckHeightStep } from './autobone-steps/CheckHeight';
import { PreparationStep } from './autobone-steps/Preparation';
-import { useState } from 'react';
-import { ProportionsResetModal } from './ProportionsResetModal';
+import { HeightContextC, useProvideHeightContext } from '@/hooks/height';
+import { CheckFloorHeightStep } from './autobone-steps/CheckFloorHeight';
export function AutomaticProportionsPage() {
const { l10n } = useLocalization();
const { applyProgress, state } = useOnboarding();
- const { sendRPCPacket } = useWebsocketAPI();
const context = useProvideAutobone();
- const { isCounting, startCountdown, timer } = useCountdown({
- onCountdownEnd: () => {
- sendRPCPacket(
- RpcMessage.SkeletonResetAllRequest,
- new SkeletonResetAllRequestT()
- );
- },
- });
-
- const [showWarning, setShowWarning] = useState(false);
+ const heightContext = useProvideHeightContext();
applyProgress(0.9);
return (
-
-
-
-
- {l10n.getString('onboarding-automatic_proportions-title')}
-
-
-
- {l10n.getString('onboarding-automatic_proportions-description')}
+
+
+
+
+
+ {l10n.getString('onboarding-automatic_proportions-title')}
-
-
-
-
-
-
-
-
setShowWarning(true)}
- disabled={isCounting}
- >
-
-
- {l10n.getString('reset-reset_all')}
+
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-description'
+ )}
+
- {!isCounting ? l10n.getString('reset-reset_all') : timer}
-
+
+
+
+
- {
- startCountdown();
- setShowWarning(false);
- }}
- onClose={() => setShowWarning(false)}
- isOpen={showWarning}
- >
-
+
);
}
diff --git a/gui/src/components/onboarding/pages/body-proportions/ProportionsChoose.tsx b/gui/src/components/onboarding/pages/body-proportions/ProportionsChoose.tsx
index d142cb78ba..4563903720 100644
--- a/gui/src/components/onboarding/pages/body-proportions/ProportionsChoose.tsx
+++ b/gui/src/components/onboarding/pages/body-proportions/ProportionsChoose.tsx
@@ -1,6 +1,6 @@
import { useOnboarding } from '@/hooks/onboarding';
import { Localized, useLocalization } from '@fluent/react';
-import { useMemo, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { Typography } from '@/components/commons/Typography';
import { Button } from '@/components/commons/Button';
@@ -9,6 +9,7 @@ import {
SkeletonConfigRequestT,
SkeletonBone,
ChangeSkeletonConfigRequestT,
+ SkeletonResetAllRequestT,
} from 'solarxr-protocol';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { save } from '@tauri-apps/plugin-dialog';
@@ -18,6 +19,7 @@ import { useAppContext } from '@/hooks/app';
import { error } from '@/utils/logging';
import { fileOpen, fileSave } from 'browser-fs-access';
import { useDebouncedEffect } from '@/hooks/timeout';
+import { ProportionsResetModal } from './ProportionsResetModal';
export const MIN_HEIGHT = 0.4;
export const MAX_HEIGHT = 4;
@@ -36,7 +38,9 @@ export function ProportionsChoose() {
const { applyProgress, state } = useOnboarding();
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
const [animated, setAnimated] = useState(false);
+ const [showProportionWarning, setShowProportionWarning] = useState(false);
const [importState, setImportState] = useState(ImportStatus.OK);
+ const exporting = useRef(false);
const { computedTrackers } = useAppContext();
useDebouncedEffect(
@@ -78,6 +82,8 @@ export function ProportionsChoose() {
useRPCPacket(
RpcMessage.SkeletonConfigResponse,
(data: SkeletonConfigExport) => {
+ if (!exporting.current) return;
+ exporting.current = false;
// Convert the skeleton part enums into a string
data.skeletonParts.forEach((x) => {
if (typeof x.bone === 'number')
@@ -151,6 +157,13 @@ export function ProportionsChoose() {
setImportState(ImportStatus.SUCCESS);
};
+ const resetAll = () => {
+ sendRPCPacket(
+ RpcMessage.SkeletonResetAllRequest,
+ new SkeletonResetAllRequestT()
+ );
+ };
+
return (
<>
@@ -217,62 +230,122 @@ export function ProportionsChoose() {
-
-
-
-
setAnimated(() => true)}
- onAnimationEnd={() => setAnimated(() => false)}
- src="/images/slimetower.webp"
- className={classNames(
- 'absolute w-[100px] -right-2 -top-24',
- animated && 'animate-[bounce_1s_1]'
- )}
- >
-
-
- {l10n.getString(
- 'onboarding-choose_proportions-auto_proportions'
- )}
-
-
- {l10n.getString(
- 'onboarding-choose_proportions-auto_proportions-subtitle'
+ {state.alonePage && (
+
+
+
+
setAnimated(() => true)}
+ onAnimationEnd={() => setAnimated(() => false)}
+ src="/images/slimetower.webp"
+ className={classNames(
+ 'absolute w-[100px] -right-2 -top-24',
+ animated && 'animate-[bounce_1s_1]'
)}
-
-
-
-
}}
- >
-
+
+
+ {l10n.getString(
+ 'onboarding-choose_proportions-auto_proportions'
+ )}
+
+
+ {l10n.getString(
+ 'onboarding-choose_proportions-auto_proportions-subtitle'
+ )}
+
+
+
+ }}
>
- Description for autobone
+
+ Description for autobone
+
+
+
+
+
+ {l10n.getString('onboarding-manual_proportions-auto')}
+
+
+
+ )}
+ {!state.alonePage && (
+
+
+
+
setAnimated(() => true)}
+ onAnimationEnd={() => setAnimated(() => false)}
+ src="/images/slimetower.webp"
+ className={classNames(
+ 'absolute w-[100px] -right-2 -top-24',
+ animated && 'animate-[bounce_1s_1]'
+ )}
+ >
+
+
+ {l10n.getString(
+ 'onboarding-choose_proportions-scaled_proportions'
+ )}
-
+
+ {l10n.getString(
+ 'onboarding-choose_proportions-scaled_proportions-subtitle'
+ )}
+
+
+
+ }}
+ >
+
+ Description for scaled proportions
+
+
+
+
+ {l10n.getString(
+ 'onboarding-choose_proportions-scaled_proportions-button'
+ )}
+
-
- {l10n.getString('onboarding-manual_proportions-auto')}
-
-
+ )}
{!state.alonePage && (
@@ -280,15 +353,33 @@ export function ProportionsChoose() {
{l10n.getString('onboarding-previous_step')}
)}
+ {state.alonePage && (
+
setShowProportionWarning(true)}
+ >
+ {l10n.getString('reset-reset_all')}
+
+ )}
+
{
+ resetAll();
+ setShowProportionWarning(false);
+ }}
+ onClose={() => setShowProportionWarning(false)}
+ isOpen={showProportionWarning}
+ >
+ onClick={() => {
+ exporting.current = true;
+
sendRPCPacket(
RpcMessage.SkeletonConfigRequest,
new SkeletonConfigRequestT()
- )
- }
+ );
+ }}
>
{l10n.getString('onboarding-choose_proportions-export')}
diff --git a/gui/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx b/gui/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx
index 62839c2a5e..233bdfb147 100644
--- a/gui/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx
+++ b/gui/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx
@@ -3,6 +3,13 @@ import { WarningBox } from '@/components/commons/TipBox';
import { Localized, useLocalization } from '@fluent/react';
import { BaseModal } from '@/components/commons/BaseModal';
import ReactModal from 'react-modal';
+import { useWebsocketAPI } from '@/hooks/websocket-api';
+import { useEffect, useState } from 'react';
+import {
+ RpcMessage,
+ SettingsRequestT,
+ SettingsResponseT,
+} from 'solarxr-protocol';
export function ProportionsResetModal({
isOpen = true,
@@ -24,6 +31,16 @@ export function ProportionsResetModal({
accept: () => void;
} & ReactModal.Props) {
const { l10n } = useLocalization();
+ const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
+ const [usingDefaultHeight, setUsingDefaultHeight] = useState(true);
+
+ useEffect(
+ () => sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()),
+ []
+ );
+ useRPCPacket(RpcMessage.SettingsResponse, (res: SettingsResponseT) =>
+ setUsingDefaultHeight(!res.modelSettings?.skeletonHeight?.hmdHeight)
+ );
return (
-
}}>
+ }}
+ >
Warning: This will reset your proportions to being just
based on your height.
diff --git a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx
new file mode 100644
index 0000000000..d9110a25f4
--- /dev/null
+++ b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx
@@ -0,0 +1,75 @@
+import { useLocalization } from '@fluent/react';
+import { useOnboarding } from '@/hooks/onboarding';
+import { Typography } from '@/components/commons/Typography';
+import { StepperSlider } from '@/components/onboarding/StepperSlider';
+import { CheckHeightStep } from './autobone-steps/CheckHeight';
+import { HeightContextC, useProvideHeightContext } from '@/hooks/height';
+import { CheckFloorHeightStep } from './autobone-steps/CheckFloorHeight';
+import { ResetProportionsStep } from './scaled-steps/ResetProportions';
+import { DoneStep } from './scaled-steps/Done';
+import { useNavigate } from 'react-router-dom';
+import { useMemo } from 'react';
+import { ManualHeightStep } from './scaled-steps/ManualHeightStep';
+import { useTrackers } from '@/hooks/tracker';
+import { BodyPart } from 'solarxr-protocol';
+
+export function ScaledProportionsPage() {
+ const { l10n } = useLocalization();
+ const { applyProgress, state } = useOnboarding();
+ const heightContext = useProvideHeightContext();
+ const navigate = useNavigate();
+ const { trackers } = useTrackers();
+
+ const hmdTracker = useMemo(
+ () =>
+ trackers.some(
+ (tracker) =>
+ tracker.tracker.info?.bodyPart === BodyPart.HEAD &&
+ (tracker.tracker.info.isHmd || tracker.tracker.position?.y)
+ ),
+ [trackers]
+ );
+
+ applyProgress(0.9);
+
+ return (
+
+
+
+
+
+ {l10n.getString('onboarding-scaled_proportions-title')}
+
+
+
+ {l10n.getString('onboarding-scaled_proportions-description')}
+
+
+
+
+
+ navigate('/onboarding/body-proportions/choose', { state })
+ }
+ >
+
+
+
+
+ );
+}
diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckFloorHeight.tsx b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckFloorHeight.tsx
new file mode 100644
index 0000000000..6469e6bb8c
--- /dev/null
+++ b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckFloorHeight.tsx
@@ -0,0 +1,207 @@
+import {
+ ChangeSettingsRequestT,
+ HeightRequestT,
+ HeightResponseT,
+ ModelSettingsT,
+ RpcMessage,
+ SkeletonHeightT,
+} from 'solarxr-protocol';
+import { useWebsocketAPI } from '@/hooks/websocket-api';
+import { Button } from '@/components/commons/Button';
+import { Typography } from '@/components/commons/Typography';
+import { Localized, useLocalization } from '@fluent/react';
+import { useEffect, useMemo, useState } from 'react';
+import { useLocaleConfig } from '@/i18n/config';
+import { useHeightContext } from '@/hooks/height';
+import { useInterval } from '@/hooks/timeout';
+import { TooSmolModal } from './TooSmolModal';
+
+export function CheckFloorHeightStep({
+ nextStep,
+ prevStep,
+ variant,
+}: {
+ nextStep: () => void;
+ prevStep: () => void;
+ variant: 'onboarding' | 'alone';
+}) {
+ const { l10n } = useLocalization();
+ const { floorHeight, hmdHeight, setFloorHeight, validateHeight } =
+ useHeightContext();
+ const [fetchHeight, setFetchHeight] = useState(false);
+ const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
+ const [isOpen, setOpen] = useState(false);
+ const { currentLocales } = useLocaleConfig();
+
+ useEffect(() => setFloorHeight(0), []);
+
+ useInterval(() => {
+ if (fetchHeight) {
+ sendRPCPacket(RpcMessage.HeightRequest, new HeightRequestT());
+ }
+ }, 100);
+
+ const mFormat = useMemo(
+ () =>
+ new Intl.NumberFormat(currentLocales, {
+ style: 'unit',
+ unit: 'meter',
+ maximumFractionDigits: 2,
+ }),
+ [currentLocales]
+ );
+
+ useRPCPacket(RpcMessage.HeightResponse, ({ minHeight }: HeightResponseT) => {
+ if (fetchHeight) {
+ setFloorHeight((val) =>
+ val === null ? minHeight : Math.min(minHeight, val)
+ );
+ }
+ });
+ return (
+ <>
+
+
+
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_floor_height-title'
+ )}
+
+
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_floor_height-description'
+ )}
+
+ }}
+ >
+
+ Press the button to get your height!
+
+
+
+
+
+ {!fetchHeight && (
+ {
+ setFloorHeight(null);
+ setFetchHeight(true);
+ }}
+ >
+
+ {l10n.getString(
+ floorHeight !== null
+ ? 'onboarding-automatic_proportions-check_floor_height-measure-reset'
+ : 'onboarding-automatic_proportions-check_floor_height-measure-start'
+ )}
+
+
+ )}
+ {fetchHeight && (
+ {
+ setFetchHeight(false);
+ }}
+ >
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_floor_height-measure-stop'
+ )}
+
+
+ )}
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_floor_height-floor_height'
+ )}
+
+
+ {floorHeight === null
+ ? l10n.getString(
+ 'onboarding-automatic_proportions-check_height-unknown'
+ )
+ : mFormat.format(floorHeight)}
+
+
+
+
+ {/* TODO: Get image of person putting controller in floor */}
+ {/*
+
+
*/}
+
+
+
+
+ {l10n.getString('onboarding-automatic_proportions-prev_step')}
+
+ {
+ if (!validateHeight(hmdHeight, 0)) {
+ setOpen(true);
+ return;
+ }
+ setFloorHeight(0);
+ const settingsRequest = new ChangeSettingsRequestT();
+ settingsRequest.modelSettings = new ModelSettingsT(
+ null,
+ null,
+ null,
+ new SkeletonHeightT(hmdHeight, 0)
+ );
+ sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest);
+
+ nextStep();
+ }}
+ >
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_floor_height-skip_step'
+ )}
+
+ {
+ if (!validateHeight(hmdHeight, floorHeight)) {
+ setOpen(true);
+ return;
+ }
+ const settingsRequest = new ChangeSettingsRequestT();
+ settingsRequest.modelSettings = new ModelSettingsT(
+ null,
+ null,
+ null,
+ new SkeletonHeightT(hmdHeight, floorHeight)
+ );
+ sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest);
+
+ nextStep();
+ }}
+ disabled={floorHeight === null || hmdHeight === null || fetchHeight}
+ >
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_floor_height-next_step'
+ )}
+
+
+
+ setOpen(false)} />
+ >
+ );
+}
diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckHeight.tsx b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckHeight.tsx
index 80943a4265..fcf900de2d 100644
--- a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckHeight.tsx
+++ b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckHeight.tsx
@@ -1,27 +1,15 @@
-import {
- AutoBoneSettingsT,
- ChangeSettingsRequestT,
- HeightRequestT,
- HeightResponseT,
- RpcMessage,
-} from 'solarxr-protocol';
+import { HeightRequestT, HeightResponseT, RpcMessage } from 'solarxr-protocol';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { Button } from '@/components/commons/Button';
import { Typography } from '@/components/commons/Typography';
import { Localized, useLocalization } from '@fluent/react';
-import { useForm } from 'react-hook-form';
import { useMemo, useState } from 'react';
-import { NumberSelector } from '@/components/commons/NumberSelector';
-import { MIN_HEIGHT } from '@/components/onboarding/pages/body-proportions/ProportionsChoose';
import { useLocaleConfig } from '@/i18n/config';
-import { useCountdown } from '@/hooks/countdown';
import { TipBox } from '@/components/commons/TipBox';
+import { useHeightContext } from '@/hooks/height';
+import { useInterval } from '@/hooks/timeout';
-interface HeightForm {
- hmdHeight: number;
-}
-
-export function CheckHeight({
+export function CheckHeightStep({
nextStep,
prevStep,
variant,
@@ -31,18 +19,17 @@ export function CheckHeight({
variant: 'onboarding' | 'alone';
}) {
const { l10n } = useLocalization();
- const { control, handleSubmit, setValue } = useForm();
- const [fetchedHeight, setFetchedHeight] = useState(false);
+ const { hmdHeight, setHmdHeight } = useHeightContext();
+ const [fetchHeight, setFetchHeight] = useState(false);
const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
- const { timer, isCounting, startCountdown } = useCountdown({
- duration: 3,
- onCountdownEnd: () => {
- setFetchedHeight(true);
- sendRPCPacket(RpcMessage.HeightRequest, new HeightRequestT());
- },
- });
const { currentLocales } = useLocaleConfig();
+ useInterval(() => {
+ if (fetchHeight) {
+ sendRPCPacket(RpcMessage.HeightRequest, new HeightRequestT());
+ }
+ }, 100);
+
const mFormat = useMemo(
() =>
new Intl.NumberFormat(currentLocales, {
@@ -53,88 +40,103 @@ export function CheckHeight({
[currentLocales]
);
- const sFormat = useMemo(
- () => new Intl.RelativeTimeFormat(currentLocales, { style: 'short' }),
- [currentLocales]
- );
-
- useRPCPacket(RpcMessage.HeightResponse, ({ hmdHeight }: HeightResponseT) => {
- setValue('hmdHeight', hmdHeight);
+ useRPCPacket(RpcMessage.HeightResponse, ({ maxHeight }: HeightResponseT) => {
+ if (fetchHeight) {
+ setHmdHeight((val) =>
+ val === null ? maxHeight : Math.max(maxHeight, val)
+ );
+ }
});
-
- const onSubmit = (values: HeightForm) => {
- const changeSettings = new ChangeSettingsRequestT();
- const autobone = new AutoBoneSettingsT();
- autobone.targetHmdHeight = values.hmdHeight;
- changeSettings.autoBoneSettings = autobone;
-
- sendRPCPacket(RpcMessage.ChangeSettingsRequest, changeSettings);
- nextStep();
- };
-
return (
<>
-
-
- {l10n.getString(
- 'onboarding-automatic_proportions-check_height-title'
- )}
-
-
-
+
+
+
{l10n.getString(
- 'onboarding-automatic_proportions-check_height-description'
+ 'onboarding-automatic_proportions-check_height-title-v2'
)}
-
}}
- >
-
- Press the button to get your height!
+
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_height-description-v2'
+ )}
-
-
-
-
}}
>
- {isCounting
- ? sFormat.format(timer, 'second')
- : l10n.getString(
- 'onboarding-automatic_proportions-check_height-fetch_height'
- )}
-
-
- {l10n.getString(
- 'onboarding-automatic_proportions-check_height-guardian_tip'
+
+ Press the button to get your height!
+
+
+
+
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_height-guardian_tip'
+ )}
+
+
+
+
+
+ {!fetchHeight && (
+ {
+ setHmdHeight(null);
+ setFetchHeight(true);
+ }}
+ >
+
+ {l10n.getString(
+ hmdHeight !== null
+ ? 'onboarding-automatic_proportions-check_height-measure-reset'
+ : 'onboarding-automatic_proportions-check_height-measure-start'
+ )}
+
+
+ )}
+ {fetchHeight && (
+ {
+ setFetchHeight(false);
+ }}
+ >
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_height-measure-stop'
+ )}
+
+
)}
-
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-check_height-hmd_height2'
+ )}
+
+
+ {hmdHeight === null
+ ? l10n.getString(
+ 'onboarding-automatic_proportions-check_height-unknown'
+ )
+ : mFormat.format(hmdHeight)}
+
+
-
@@ -146,8 +148,8 @@ export function CheckHeight({
{l10n.getString(
'onboarding-automatic_proportions-check_height-next_step'
diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx
index 81f9300a89..61b0be5ec1 100644
--- a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx
+++ b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx
@@ -41,7 +41,7 @@ export function PreparationStep({
diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx
new file mode 100644
index 0000000000..8c446b5651
--- /dev/null
+++ b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx
@@ -0,0 +1,73 @@
+import { BaseModal } from '@/components/commons/BaseModal';
+import { Button } from '@/components/commons/Button';
+import { WarningBox } from '@/components/commons/TipBox';
+import { useHeightContext } from '@/hooks/height';
+import { useLocaleConfig } from '@/i18n/config';
+import { Localized, useLocalization } from '@fluent/react';
+import { useMemo } from 'react';
+import { MIN_HEIGHT } from '@/components/onboarding/pages/body-proportions/ProportionsChoose';
+
+export function TooSmolModal({
+ isOpen = true,
+ onClose,
+ ...props
+}: {
+ /**
+ * Is the parent/sibling component opened?
+ */
+ isOpen: boolean;
+ /**
+ * Function to trigger when the warning hasn't been accepted
+ */
+ onClose: () => void;
+} & ReactModal.Props) {
+ const { l10n } = useLocalization();
+ const { hmdHeight, floorHeight } = useHeightContext();
+ const { currentLocales } = useLocaleConfig();
+
+ const mFormat = useMemo(
+ () =>
+ new Intl.NumberFormat(currentLocales, {
+ style: 'unit',
+ unit: 'meter',
+ maximumFractionDigits: 2,
+ }),
+ [currentLocales]
+ );
+
+ return (
+
+
+
+
}}
+ vars={{
+ height: mFormat.format(hmdHeight ?? 0 - (floorHeight ?? 0)),
+ minHeight: mFormat.format(MIN_HEIGHT),
+ }}
+ >
+
+ Warning: You are too smol to continue
+
+
+
+
+
+ {l10n.getString(
+ 'onboarding-automatic_proportions-smol_warning-cancel'
+ )}
+
+
+
+
+
+ );
+}
diff --git a/gui/src/components/onboarding/pages/body-proportions/scaled-steps/Done.tsx b/gui/src/components/onboarding/pages/body-proportions/scaled-steps/Done.tsx
new file mode 100644
index 0000000000..a782d8401c
--- /dev/null
+++ b/gui/src/components/onboarding/pages/body-proportions/scaled-steps/Done.tsx
@@ -0,0 +1,30 @@
+import { Typography } from '@/components/commons/Typography';
+import { useLocalization } from '@fluent/react';
+import { Button } from '@/components/commons/Button';
+import { SkeletonVisualizerWidget } from '@/components/widgets/SkeletonVisualizerWidget';
+
+export function DoneStep({ variant }: { variant: 'onboarding' | 'alone' }) {
+ const { l10n } = useLocalization();
+
+ return (
+
+
+
+ {l10n.getString('onboarding-scaled_proportions-done-title')}
+
+
+ {l10n.getString('onboarding-scaled_proportions-done-description')}
+
+
+
+
+ {variant === 'onboarding' && (
+
+ {l10n.getString('onboarding-continue')}
+
+ )}
+
+
+
+ );
+}
diff --git a/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ManualHeightStep.tsx b/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ManualHeightStep.tsx
new file mode 100644
index 0000000000..6e548293bf
--- /dev/null
+++ b/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ManualHeightStep.tsx
@@ -0,0 +1,158 @@
+import { useWebsocketAPI } from '@/hooks/websocket-api';
+import { Button } from '@/components/commons/Button';
+import { Typography } from '@/components/commons/Typography';
+import { Localized, useLocalization } from '@fluent/react';
+import { useMemo } from 'react';
+import { useLocaleConfig } from '@/i18n/config';
+import { useHeightContext } from '@/hooks/height';
+import { useForm } from 'react-hook-form';
+import {
+ ChangeSettingsRequestT,
+ ModelSettingsT,
+ RpcMessage,
+ SkeletonHeightT,
+ StatusData,
+ StatusSteamVRDisconnectedT,
+} from 'solarxr-protocol';
+import { NumberSelector } from '@/components/commons/NumberSelector';
+import { MIN_HEIGHT } from '@/components/onboarding/pages/body-proportions/ProportionsChoose';
+import { WarningBox } from '@/components/commons/TipBox';
+import { useStatusContext } from '@/hooks/status-system';
+
+interface HeightForm {
+ height: number;
+}
+
+export function ManualHeightStep({
+ nextStep,
+ prevStep,
+ variant,
+}: {
+ nextStep: () => void;
+ prevStep: () => void;
+ variant: 'onboarding' | 'alone';
+}) {
+ const { l10n } = useLocalization();
+ const { hmdHeight, setHmdHeight } = useHeightContext();
+ const { control, handleSubmit } = useForm({
+ defaultValues: { height: 1.5 },
+ });
+ const { sendRPCPacket } = useWebsocketAPI();
+ const { currentLocales } = useLocaleConfig();
+ const { statuses } = useStatusContext();
+
+ const missingSteamConnection = useMemo(
+ () =>
+ Object.values(statuses).some(
+ (x) =>
+ x.dataType === StatusData.StatusSteamVRDisconnected &&
+ (x.data as StatusSteamVRDisconnectedT).bridgeSettingsName ===
+ 'steamvr'
+ ),
+ [statuses]
+ );
+
+ const mFormat = useMemo(
+ () =>
+ new Intl.NumberFormat(currentLocales, {
+ style: 'unit',
+ unit: 'meter',
+ maximumFractionDigits: 2,
+ }),
+ [currentLocales]
+ );
+
+ handleSubmit((values) => {
+ setHmdHeight(values.height);
+ });
+
+ return (
+ <>
+
+
+
+
+ {l10n.getString(
+ 'onboarding-scaled_proportions-manual_height-title'
+ )}
+
+
+
+ {l10n.getString(
+ 'onboarding-scaled_proportions-manual_height-description'
+ )}
+
+ {/*
}}
+ >
+
+ Input your height manually!
+
+ */}
+ {missingSteamConnection && (
+
+ }}
+ // TODO: Add link to docs!
+ >
+ You don't have SteamVR connected!
+
+
+ )}
+
+
+
+
+
+
+
+ {l10n.getString('onboarding-automatic_proportions-prev_step')}
+
+ {
+ const settingsRequest = new ChangeSettingsRequestT();
+ settingsRequest.modelSettings = new ModelSettingsT(
+ null,
+ null,
+ null,
+ new SkeletonHeightT(hmdHeight, 0)
+ );
+ sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest);
+ nextStep();
+ }}
+ >
+ {l10n.getString(
+ 'onboarding-scaled_proportions-manual_height-next_step'
+ )}
+
+
+
+ >
+ );
+}
diff --git a/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ResetProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ResetProportions.tsx
new file mode 100644
index 0000000000..ac8fdc3d19
--- /dev/null
+++ b/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ResetProportions.tsx
@@ -0,0 +1,62 @@
+import { RpcMessage, SkeletonResetAllRequestT } from 'solarxr-protocol';
+import { Button } from '@/components/commons/Button';
+import { Typography } from '@/components/commons/Typography';
+import { useLocalization } from '@fluent/react';
+import { useWebsocketAPI } from '@/hooks/websocket-api';
+
+export function ResetProportionsStep({
+ nextStep,
+ prevStep,
+ variant,
+}: {
+ nextStep: () => void;
+ prevStep: () => void;
+ variant: 'onboarding' | 'alone';
+}) {
+ const { l10n } = useLocalization();
+ const { sendRPCPacket } = useWebsocketAPI();
+
+ return (
+ <>
+
+
+
+ {l10n.getString(
+ 'onboarding-scaled_proportions-reset_proportion-title'
+ )}
+
+
+
+ {l10n.getString(
+ 'onboarding-scaled_proportions-reset_proportion-description'
+ )}
+
+
+
+
+
+
+
+ {l10n.getString('onboarding-automatic_proportions-prev_step')}
+
+ {
+ sendRPCPacket(
+ RpcMessage.SkeletonResetAllRequest,
+ new SkeletonResetAllRequestT()
+ );
+ nextStep();
+ }}
+ >
+ {l10n.getString('reset-reset_all')}
+
+
+
+
+ >
+ );
+}
diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx
index d384670e79..3946b551fc 100644
--- a/gui/src/components/settings/pages/GeneralSettings.tsx
+++ b/gui/src/components/settings/pages/GeneralSettings.tsx
@@ -69,6 +69,9 @@ interface SettingsForm {
toeSnap: boolean;
footPlant: boolean;
selfLocalization: boolean;
+ usePosition: boolean;
+ enforceConstraints: boolean;
+ correctConstraints: boolean;
};
ratios: {
imputeWaistFromChestHip: number;
@@ -128,6 +131,9 @@ const defaultValues: SettingsForm = {
toeSnap: false,
footPlant: true,
selfLocalization: false,
+ usePosition: true,
+ enforceConstraints: true,
+ correctConstraints: true,
},
ratios: {
imputeWaistFromChestHip: 0.3,
@@ -235,6 +241,9 @@ export function GeneralSettings() {
toggles.toeSnap = values.toggles.toeSnap;
toggles.footPlant = values.toggles.footPlant;
toggles.selfLocalization = values.toggles.selfLocalization;
+ toggles.usePosition = values.toggles.usePosition;
+ toggles.enforceConstraints = values.toggles.enforceConstraints;
+ toggles.correctConstraints = values.toggles.correctConstraints;
modelSettings.toggles = toggles;
}
@@ -981,6 +990,7 @@ export function GeneralSettings() {
)}
/>
+
{l10n.getString(
'settings-general-fk_settings-arm_fk-reset_mode-description'
@@ -1033,6 +1043,48 @@ export function GeneralSettings() {
>
+
+
+ {l10n.getString(
+ 'settings-general-fk_settings-enforce_joint_constraints'
+ )}
+
+
+ {l10n.getString(
+ 'settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description'
+ )}
+
+
+
+
+
+
+
+ {l10n.getString(
+ 'settings-general-fk_settings-enforce_joint_constraints-correct_constraints-description'
+ )}
+
+
+
+
+
+
{config?.debug && (
<>
diff --git a/gui/src/hooks/height.ts b/gui/src/hooks/height.ts
new file mode 100644
index 0000000000..5dffb530d9
--- /dev/null
+++ b/gui/src/hooks/height.ts
@@ -0,0 +1,58 @@
+import { createContext, useContext, useEffect, useState } from 'react';
+import { useWebsocketAPI } from './websocket-api';
+import { RpcMessage, SettingsRequestT, SettingsResponseT } from 'solarxr-protocol';
+import { MIN_HEIGHT } from '@/components/onboarding/pages/body-proportions/ProportionsChoose';
+
+export interface HeightContext {
+ hmdHeight: number | null;
+ setHmdHeight: React.Dispatch
>;
+ floorHeight: number | null;
+ setFloorHeight: React.Dispatch>;
+ validateHeight: (
+ hmdHeight: number | null | undefined,
+ floorHeight: number | null | undefined
+ ) => boolean;
+}
+
+export function useProvideHeightContext(): HeightContext {
+ const [hmdHeight, setHmdHeight] = useState(null);
+ const [floorHeight, setFloorHeight] = useState(null);
+ const { sendRPCPacket, useRPCPacket } = useWebsocketAPI();
+
+ function validateHeight(
+ hmdHeight: number | null | undefined,
+ floorHeight: number | null | undefined
+ ) {
+ return (
+ hmdHeight !== undefined &&
+ hmdHeight !== null &&
+ hmdHeight - (floorHeight ?? 0) > MIN_HEIGHT
+ );
+ }
+
+ useEffect(
+ () => sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()),
+ []
+ );
+ useRPCPacket(RpcMessage.SettingsResponse, (res: SettingsResponseT) => {
+ const hmd = res.modelSettings?.skeletonHeight?.hmdHeight;
+ const floor = res.modelSettings?.skeletonHeight?.floorHeight;
+
+ if (validateHeight(hmd, floor)) {
+ setHmdHeight(hmd ?? null);
+ setFloorHeight(floor ?? null);
+ }
+ });
+
+ return { hmdHeight, setHmdHeight, floorHeight, setFloorHeight, validateHeight };
+}
+
+export const HeightContextC = createContext(undefined as never);
+
+export function useHeightContext() {
+ const context = useContext(HeightContextC);
+ if (!context) {
+ throw new Error('useHeightContext must be within a HeightContext Provider');
+ }
+ return context;
+}
diff --git a/server/build.gradle.kts b/server/build.gradle.kts
index 11ddef897f..a273d1a986 100644
--- a/server/build.gradle.kts
+++ b/server/build.gradle.kts
@@ -20,7 +20,7 @@ configure {
// define the steps to apply to those files
trimTrailingWhitespace()
endWithNewline()
- indentWithTabs()
+ leadingSpacesToTabs()
}
// format "yaml", {
// target "*.yml", "*.yaml",
@@ -61,6 +61,8 @@ configure {
removeUnusedImports()
// Use eclipse JDT formatter
- eclipse().configFile("spotless.xml")
+ eclipse()
+ .configFile("spotless.xml")
+ .withP2Mirrors(mapOf("https://download.eclipse.org/" to "https://mirror.umd.edu/eclipse/"))
}
}
diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt
index ab43a62987..23b35939ff 100644
--- a/server/core/src/main/java/dev/slimevr/VRServer.kt
+++ b/server/core/src/main/java/dev/slimevr/VRServer.kt
@@ -38,16 +38,15 @@ import java.util.concurrent.atomic.AtomicInteger
import java.util.function.Consumer
import kotlin.concurrent.schedule
-typealias SteamBridgeProvider = (
+typealias BridgeProvider = (
server: VRServer,
computedTrackers: List,
-) -> ISteamVRBridge?
+) -> Sequence
const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR"
class VRServer @JvmOverloads constructor(
- driverBridgeProvider: SteamBridgeProvider = { _, _ -> null },
- feederBridgeProvider: (VRServer) -> ISteamVRBridge? = { _ -> null },
+ bridgeProvider: BridgeProvider = { _, _ -> sequence {} },
serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() },
flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null },
acquireMulticastLock: () -> Any? = { null },
@@ -135,22 +134,11 @@ class VRServer @JvmOverloads constructor(
"Sensors UDP server",
) { tracker: Tracker -> registerTracker(tracker) }
- // Start bridges for SteamVR and Feeder
- val driverBridge = driverBridgeProvider(this, computedTrackers)
- if (driverBridge != null) {
- tasks.add(Runnable { driverBridge.startBridge() })
- bridges.add(driverBridge)
+ // Start bridges and WebSocket server
+ for (bridge in bridgeProvider(this, computedTrackers) + sequenceOf(WebSocketVRBridge(computedTrackers, this))) {
+ tasks.add(Runnable { bridge.startBridge() })
+ bridges.add(bridge)
}
- val feederBridge = feederBridgeProvider(this)
- if (feederBridge != null) {
- tasks.add(Runnable { feederBridge.startBridge() })
- bridges.add(feederBridge)
- }
-
- // Create WebSocket server
- val wsBridge = WebSocketVRBridge(computedTrackers, this)
- tasks.add(Runnable { wsBridge.startBridge() })
- bridges.add(wsBridge)
// Initialize OSC handlers
vrcOSCHandler = VRCOSCHandler(
diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt
index faf714f2d2..bf7e606302 100644
--- a/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt
+++ b/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt
@@ -4,6 +4,7 @@ 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.PoseFrameIO
import dev.slimevr.poseframeformat.PoseFrames
import dev.slimevr.tracking.processor.BoneType
@@ -23,7 +24,7 @@ import java.util.function.Consumer
import java.util.function.Function
import kotlin.math.*
-class AutoBone(server: VRServer) {
+class AutoBone(private val server: VRServer) {
// This is filled by loadConfigValues()
val offsets = EnumMap(
SkeletonConfigOffsets::class.java,
@@ -49,8 +50,6 @@ class AutoBone(server: VRServer) {
// The total height of the normalized adjusted offsets
var adjustedHeightNormalized: Float = 1f
- private val server: VRServer
-
// #region Error functions
var slideError = SlideError()
var offsetSlideError = OffsetSlideError()
@@ -63,11 +62,10 @@ class AutoBone(server: VRServer) {
private val rand = Random()
- val globalConfig: AutoBoneConfig
+ val globalConfig: AutoBoneConfig = server.configManager.vrConfig.autoBone
+ val globalSkeletonConfig: SkeletonConfig = server.configManager.vrConfig.skeleton
init {
- globalConfig = server.configManager.vrConfig.autoBone
- this.server = server
loadConfigValues()
}
@@ -191,6 +189,7 @@ class AutoBone(server: VRServer) {
// 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
@@ -229,6 +228,7 @@ class AutoBone(server: VRServer) {
fun processFrames(
frames: PoseFrames,
config: AutoBoneConfig = globalConfig,
+ skeletonConfig: SkeletonConfig = globalSkeletonConfig,
epochCallback: Consumer? = null,
): AutoBoneResults {
check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." }
@@ -238,16 +238,11 @@ class AutoBone(server: VRServer) {
loadConfigValues()
// Set the target heights either from config or calculate them
- val targetHmdHeight = if (config.targetHmdHeight > 0f) {
- config.targetHmdHeight
+ val targetHmdHeight = if (skeletonConfig.userHeight > MIN_HEIGHT) {
+ skeletonConfig.userHeight
} else {
calcTargetHmdHeight(frames, config)
}
- val targetFullHeight = if (config.targetFullHeight > 0f) {
- config.targetFullHeight
- } else {
- targetHmdHeight / BodyProportionError.eyeHeightToHeightRatio
- }
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
@@ -255,7 +250,6 @@ class AutoBone(server: VRServer) {
val trainingStep = AutoBoneStep(
config = config,
targetHmdHeight = targetHmdHeight,
- targetFullHeight = targetFullHeight,
frames = frames,
epochCallback = epochCallback,
serverConfig = server.configManager,
diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt
index a0b802f679..bbcce83814 100644
--- a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt
+++ b/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt
@@ -10,7 +10,6 @@ import java.util.function.Consumer
class AutoBoneStep(
val config: AutoBoneConfig,
val targetHmdHeight: Float,
- val targetFullHeight: Float,
val frames: PoseFrames,
val epochCallback: Consumer?,
serverConfig: ConfigManager,
@@ -20,9 +19,6 @@ class AutoBoneStep(
var cursor2: Int = 0,
var currentHmdHeight: Float = 0f,
) {
-
- val eyeHeightToHeightRatio: Float = targetHmdHeight / targetFullHeight
-
var maxFrameCount = frames.maxFrameCount
val framePlayer1 = TrackerFramesPlayer(frames)
@@ -40,7 +36,7 @@ class AutoBoneStep(
// Load server configs into the skeleton
skeleton1.loadFromConfig(serverConfig)
skeleton2.loadFromConfig(serverConfig)
- // Disable leg tweaks, this will mess with the resulting positions
+ // Disable leg tweaks and IK solver, these will mess with the resulting positions
skeleton1.setLegTweaksEnabled(false)
skeleton2.setLegTweaksEnabled(false)
}
diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt
index 51d2649854..c8f0912c56 100644
--- a/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt
+++ b/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt
@@ -4,6 +4,7 @@ import dev.slimevr.autobone.AutoBoneStep
import dev.slimevr.autobone.errors.proportions.ProportionLimiter
import dev.slimevr.autobone.errors.proportions.RangeProportionLimiter
import dev.slimevr.tracking.processor.HumanPoseManager
+import dev.slimevr.tracking.processor.config.SkeletonConfigManager
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import kotlin.math.*
@@ -32,84 +33,58 @@ class BodyProportionError : IAutoBoneError {
@JvmField
var eyeHeightToHeightRatio = 0.936f
- // Default config
- // Height: 1.58
- // Full Height: 1.58 / 0.936 = 1.688034
- // Neck: 0.1 / 1.688034 = 0.059241
- // Torso: 0.56 / 1.688034 = 0.331747
- // Upper Chest: 0.16 / 1.688034 = 0.094784
- // Chest: 0.16 / 1.688034 = 0.094784
- // Waist: (0.56 - 0.32 - 0.04) / 1.688034 = 0.118481
- // Hip: 0.04 / 1.688034 = 0.023696
- // Hip Width: 0.26 / 1.688034 = 0.154025
- // Upper Leg: (0.92 - 0.50) / 1.688034 = 0.24881
- // Lower Leg: 0.50 / 1.688034 = 0.296203
+ private val defaultHeight = SkeletonConfigManager.HEIGHT_OFFSETS.sumOf { it.defaultValue.toDouble() }.toFloat()
+ private fun makeLimiter(offset: SkeletonConfigOffsets, range: Float): RangeProportionLimiter = RangeProportionLimiter(
+ offset.defaultValue / defaultHeight,
+ offset,
+ range,
+ )
+
// "Expected" are values from Drillis and Contini (1966)
- // "Experimental" are values from experimentation by the SlimeVR community
+ // 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(
- // Head
- // Experimental: 0.059
- RangeProportionLimiter(
- 0.059f,
+ makeLimiter(
SkeletonConfigOffsets.HEAD,
0.01f,
),
- // Neck
// Expected: 0.052
- // Experimental: 0.059
- RangeProportionLimiter(
- 0.054f,
+ makeLimiter(
SkeletonConfigOffsets.NECK,
- 0.0015f,
+ 0.002f,
),
- // Upper Chest
- // Experimental: 0.0945
- RangeProportionLimiter(
- 0.0945f,
+ makeLimiter(
SkeletonConfigOffsets.UPPER_CHEST,
0.01f,
),
- // Chest
- // Experimental: 0.0945
- RangeProportionLimiter(
- 0.0945f,
+ makeLimiter(
SkeletonConfigOffsets.CHEST,
0.01f,
),
- // Waist
- // Experimental: 0.118
- RangeProportionLimiter(
- 0.118f,
+ makeLimiter(
SkeletonConfigOffsets.WAIST,
0.05f,
),
- // Hip
- // Experimental: 0.0237
- RangeProportionLimiter(
- 0.0237f,
+ makeLimiter(
SkeletonConfigOffsets.HIP,
0.01f,
),
- // Hip Width
// Expected: 0.191
- // Experimental: 0.154
- RangeProportionLimiter(
- 0.184f,
+ makeLimiter(
SkeletonConfigOffsets.HIPS_WIDTH,
0.04f,
),
- // Upper Leg
// Expected: 0.245
- RangeProportionLimiter(
- 0.245f,
+ makeLimiter(
SkeletonConfigOffsets.UPPER_LEG,
- 0.015f,
+ 0.02f,
),
- // Lower Leg
// Expected: 0.246 (0.285 including below ankle, could use a separate
// offset?)
- RangeProportionLimiter(
- 0.285f,
+ makeLimiter(
SkeletonConfigOffsets.LOWER_LEG,
0.02f,
),
diff --git a/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt b/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt
index 3c7cf20d94..3d3c62f22a 100644
--- a/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt
+++ b/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt
@@ -16,8 +16,6 @@ class AutoBoneConfig {
var positionErrorFactor = 0.0f
var positionOffsetErrorFactor = 0.0f
var calcInitError = false
- var targetHmdHeight = -1f
- var targetFullHeight = -1f
var randomizeFrameOrder = true
var scaleEachStep = true
var sampleCount = 1500
diff --git a/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java b/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java
index ce6e436b2d..76dc56d261 100644
--- a/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java
+++ b/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java
@@ -309,6 +309,15 @@ public ObjectNode convert(
// 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 (
diff --git a/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java b/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java
index 07ec694f3e..27e6b3c3e8 100644
--- a/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java
+++ b/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java
@@ -1,5 +1,6 @@
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;
@@ -24,6 +25,9 @@ public class SkeletonConfig {
@JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class)
public Map offsets = new HashMap<>();
+ private float hmdHeight = 0f;
+ private float floorHeight = 0f;
+
public Map getToggles() {
return toggles;
}
@@ -35,4 +39,25 @@ public Map getOffsets() {
public Map 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;
+ }
}
diff --git a/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt b/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt
index a13b4d6323..606210e8e7 100644
--- a/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt
+++ b/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt
@@ -21,6 +21,8 @@ class QuaternionMovingAverage(
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? = null
@@ -30,7 +32,6 @@ class QuaternionMovingAverage(
private val fpsTimer = if (VRServer.instanceInitialized) VRServer.instance.fpsTimer else NanoTimer()
private var frameCounter = 0
private var lastAmt = 0f
- var filteredQuaternion = IDENTITY
init {
// amount should range from 0 to 1.
@@ -98,6 +99,8 @@ class QuaternionMovingAverage(
// No filtering; just keep track of rotations (for going over 180 degrees)
filteredQuaternion = latestQuaternion.twinNearest(temporalQuaternion)
}
+
+ filteringImpact = latestQuaternion.angleToR(filteredQuaternion)
}
@Synchronized
diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt
index 850644ea1e..19165b7c81 100644
--- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt
+++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt
@@ -1,7 +1,6 @@
package dev.slimevr.protocol.rpc
import com.google.flatbuffers.FlatBufferBuilder
-import dev.slimevr.autobone.errors.BodyProportionError
import dev.slimevr.config.config
import dev.slimevr.protocol.GenericConnection
import dev.slimevr.protocol.ProtocolAPI
@@ -20,6 +19,7 @@ import dev.slimevr.protocol.rpc.status.RPCStatusHandler
import dev.slimevr.protocol.rpc.trackingpause.RPCTrackingPause
import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets
import dev.slimevr.tracking.trackers.TrackerPosition.Companion.getByBodyPart
+import dev.slimevr.tracking.trackers.TrackerStatus
import dev.slimevr.tracking.trackers.TrackerUtils.getTrackerForSkeleton
import io.eiren.util.logging.LogManager
import io.github.axisangles.ktmath.Quaternion
@@ -464,13 +464,22 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler = CopyOnWriteArrayList()
var rotationOffset = Quaternion.IDENTITY
+ var attachedTracker: Tracker? = null
init {
headNode.attachChild(tailNode)
@@ -58,6 +62,50 @@ class Bone(val boneType: BoneType) {
headNode.update()
}
+ /**
+ * Computes the rotations and positions of
+ * this bone and all of its children while
+ * enforcing rotation constraints.
+ */
+ fun updateWithConstraints(correctConstraints: Boolean) {
+ val initialRot = getGlobalRotation()
+ val newRot = rotationConstraint.applyConstraint(initialRot, this)
+ setRotationRaw(newRot)
+ updateThisNode()
+
+ // Correct tracker if applicable. Do not adjust correction for hinge constraints
+ // or the upper chest tracker.
+ if (rotationConstraint.constraintType != ConstraintType.HINGE &&
+ rotationConstraint.constraintType != ConstraintType.LOOSE_HINGE &&
+ boneType.bodyPart != BodyPart.UPPER_CHEST
+ ) {
+ val deltaRot = newRot * initialRot.inv()
+ val angle = deltaRot.angleR()
+
+ if (correctConstraints &&
+ angle > Constraint.ANGLE_THRESHOLD &&
+ (attachedTracker?.filteringHandler?.getFilteringImpact() ?: 1f) < Constraint.FILTER_IMPACT_THRESHOLD &&
+ (parent?.attachedTracker?.filteringHandler?.getFilteringImpact() ?: 0f) < Constraint.FILTER_IMPACT_THRESHOLD
+ ) {
+ attachedTracker?.resetsHandler?.updateConstraintFix(deltaRot)
+ }
+ }
+
+ // Recursively apply constraints and update children.
+ for (child in children) {
+ child.updateWithConstraints(correctConstraints)
+ }
+ }
+
+ /**
+ * Computes the rotations and positions of this bone.
+ * Only to be used while traversing bones from top to bottom.
+ */
+ private fun updateThisNode() {
+ headNode.updateThisNode()
+ tailNode.updateThisNode()
+ }
+
/**
* Returns the world-aligned rotation of the bone
*/
@@ -75,6 +123,13 @@ class Bone(val boneType: BoneType) {
headNode.localTransform.rotation = rotation * rotationOffset
}
+ /**
+ * Sets the global rotation of the bone directly
+ */
+ fun setRotationRaw(rotation: Quaternion) {
+ headNode.localTransform.rotation = rotation
+ }
+
/**
* Returns the global position of the head of the bone
*/
diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java b/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java
index 0e263992bd..b423640103 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java
+++ b/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java
@@ -36,6 +36,8 @@ public enum BoneType {
RIGHT_UPPER_ARM(BodyPart.RIGHT_UPPER_ARM),
LEFT_SHOULDER(BodyPart.LEFT_SHOULDER),
RIGHT_SHOULDER(BodyPart.RIGHT_SHOULDER),
+ LEFT_UPPER_SHOULDER,
+ RIGHT_UPPER_SHOULDER,
LEFT_HAND(BodyPart.LEFT_HAND),
RIGHT_HAND(BodyPart.RIGHT_HAND),
LEFT_HAND_TRACKER,
diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt
new file mode 100644
index 0000000000..91c6d34ff1
--- /dev/null
+++ b/server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt
@@ -0,0 +1,191 @@
+package dev.slimevr.tracking.processor
+
+import com.jme3.math.FastMath
+import io.github.axisangles.ktmath.Quaternion
+import io.github.axisangles.ktmath.Vector3
+import kotlin.math.*
+
+/**
+ * Represents a function that applies a rotational constraint.
+ */
+typealias ConstraintFunction = (localRotation: Quaternion, limit1: Float, limit2: Float, limit3: Float) -> Quaternion
+
+/**
+ * Represents the rotational limits of a Bone relative to its parent,
+ * twist and swing are the max and min when constraintType is a hinge.
+ * Twist, swing, allowedDeviation, and maxDeviationFromTracker represent
+ * an angle in degrees.
+ */
+class Constraint(
+ val constraintType: ConstraintType,
+ twist: Float = 0.0f,
+ swing: Float = 0.0f,
+ allowedDeviation: Float = 0f,
+ maxDeviationFromTracker: Float = 15f,
+) {
+ private val constraintFunction = constraintTypeToFunc(constraintType)
+ private val twistRad = twist * FastMath.DEG_TO_RAD
+ private val swingRad = swing * FastMath.DEG_TO_RAD
+ private val allowedDeviationRad = allowedDeviation * FastMath.DEG_TO_RAD
+ private val maxDeviationFromTrackerRad = maxDeviationFromTracker * FastMath.DEG_TO_RAD
+
+ /**
+ * allowModification may be false for reasons other than a tracker being on this bone
+ * while hasTrackerRotation is only true if this bone has a tracker. These values are
+ * to be used with an IK solver and are not currently set accurately
+ */
+ var allowModifications = true
+ var hasTrackerRotation = false
+
+ /**
+ * The rotation before any IK solve takes place. Again this value is not currently set accurately
+ */
+ var initialRotation = Quaternion.IDENTITY
+
+ /**
+ * Apply rotational constraints and if applicable force the rotation
+ * to be unchanged unless it violates the constraints
+ */
+ fun applyConstraint(rotation: Quaternion, thisBone: Bone): Quaternion {
+ // When constraints are being used during a IK solve the input rotation is not necessarily
+ // the bones global rotation, thus complete constraints must be specifically handled.
+ if (constraintType == ConstraintType.COMPLETE) return thisBone.getGlobalRotation()
+
+ // If there is no parent and this is not a complete constraint accept the rotation as is.
+ if (thisBone.parent == null) return rotation
+
+ val localRotation = getLocalRotation(rotation, thisBone)
+ val constrainedRotation = constraintFunction(localRotation, swingRad, twistRad, allowedDeviationRad)
+ return getWorldRotationFromLocal(constrainedRotation, thisBone)
+ }
+
+ /**
+ * Force the given rotation to be within allowedDeviation degrees away from
+ * initialRotation on both the twist and swing axis
+ */
+ fun constrainToInitialRotation(rotation: Quaternion): Quaternion {
+ val rotationLocal = rotation * initialRotation.inv()
+ var (swingQ, twistQ) = decompose(rotationLocal, Vector3.NEG_Y)
+ swingQ = constrain(swingQ, maxDeviationFromTrackerRad)
+ twistQ = constrain(twistQ, maxDeviationFromTrackerRad)
+ return initialRotation * (swingQ * twistQ)
+ }
+
+ companion object {
+ const val ANGLE_THRESHOLD = 0.004f // == 0.25 degrees
+ const val FILTER_IMPACT_THRESHOLD = 0.0349f // == 2 degrees
+
+ enum class ConstraintType {
+ TWIST_SWING,
+ HINGE,
+ LOOSE_HINGE,
+ COMPLETE,
+ }
+
+ private fun constraintTypeToFunc(type: ConstraintType) =
+ when (type) {
+ ConstraintType.COMPLETE -> completeConstraint
+ ConstraintType.TWIST_SWING -> twistSwingConstraint
+ ConstraintType.HINGE -> hingeConstraint
+ ConstraintType.LOOSE_HINGE -> looseHingeConstraint
+ }
+
+ private fun getLocalRotation(rotation: Quaternion, thisBone: Bone): Quaternion {
+ val parent = thisBone.parent!!
+ val localRotationOffset = parent.rotationOffset.inv() * thisBone.rotationOffset
+ return (parent.getGlobalRotation() * localRotationOffset).inv() * rotation
+ }
+
+ private fun getWorldRotationFromLocal(rotation: Quaternion, thisBone: Bone): Quaternion {
+ val parent = thisBone.parent!!
+ val localRotationOffset = parent.rotationOffset.inv() * thisBone.rotationOffset
+ return (parent.getGlobalRotation() * localRotationOffset * rotation).unit()
+ }
+
+ private fun decompose(
+ rotation: Quaternion,
+ twistAxis: Vector3,
+ ): Pair {
+ val projection = rotation.project(twistAxis).unit()
+ val twist = Quaternion(sqrt(1.0f - projection.xyz.lenSq()) * if (rotation.w >= 0f) 1f else -1f, projection.xyz).unit()
+ val swing = (rotation * twist.inv()).unit()
+ return Pair(swing, twist)
+ }
+
+ private fun constrain(rotation: Quaternion, angle: Float): Quaternion {
+ // Use angle to get the maximum magnitude the vector part of rotation can be
+ // before it has violated a constraint.
+ // Multiplying by 0.5 uniquely maps angles 0-180 degrees to 0-1 which works
+ // nicely with unit quaternions.
+ val magnitude = sin(angle * 0.5f)
+ val magnitudeSqr = magnitude * magnitude
+ val sign = if (rotation.w >= 0f) 1f else -1f
+ var vector = rotation.xyz
+ var rot = rotation
+
+ if (vector.lenSq() > magnitudeSqr) {
+ vector = vector.unit() * magnitude
+ rot = Quaternion(sqrt(1.0f - magnitudeSqr) * sign, vector)
+ }
+
+ return rot.unit()
+ }
+
+ private fun constrain(rotation: Quaternion, minAngle: Float, maxAngle: Float, axis: Vector3): Quaternion {
+ val magnitudeMin = sin(minAngle * 0.5f)
+ val magnitudeMax = sin(maxAngle * 0.5f)
+ val magnitudeSqrMin = magnitudeMin * magnitudeMin * if (minAngle >= 0f) 1f else -1f
+ val magnitudeSqrMax = magnitudeMax * magnitudeMax * if (maxAngle >= 0f) 1f else -1f
+ var vector = rotation.xyz
+ var rot = rotation
+
+ val rotMagnitude = vector.lenSq() * if (vector.dot(axis) * sign(rot.w) < 0) -1f else 1f
+ if (rotMagnitude < magnitudeSqrMin || rotMagnitude > magnitudeSqrMax) {
+ val distToMin = min(abs(rotMagnitude - magnitudeSqrMin), abs(rotMagnitude + magnitudeSqrMin))
+ val distToMax = min(abs(rotMagnitude - magnitudeSqrMax), abs(rotMagnitude + magnitudeSqrMax))
+
+ val magnitude = if (distToMin < distToMax) magnitudeMin else magnitudeMax
+ val magnitudeSqr = abs(if (distToMin < distToMax) magnitudeSqrMin else magnitudeSqrMax)
+ vector = vector.unit() * -magnitude
+
+ rot = Quaternion(sqrt(1.0f - magnitudeSqr), vector)
+ }
+
+ return rot.unit()
+ }
+
+ // Constraint function for TwistSwingConstraint
+ private val twistSwingConstraint: ConstraintFunction =
+ { rotation: Quaternion, swingRad: Float, twistRad: Float, _: Float ->
+ var (swingQ, twistQ) = decompose(rotation, Vector3.NEG_Y)
+ swingQ = constrain(swingQ, swingRad)
+ twistQ = constrain(twistQ, twistRad)
+
+ swingQ * twistQ
+ }
+
+ // Constraint function for a hinge constraint with min and max angles
+ private val hingeConstraint: ConstraintFunction =
+ { rotation: Quaternion, min: Float, max: Float, _: Float ->
+ val (_, hingeAxisRot) = decompose(rotation, Vector3.NEG_X)
+
+ constrain(hingeAxisRot, min, max, Vector3.NEG_X)
+ }
+
+ // Constraint function for a hinge constraint with min and max angles that allows nonHingeDeviation
+ // rotation on all axis but the hinge
+ private val looseHingeConstraint: ConstraintFunction =
+ { rotation: Quaternion, min: Float, max: Float, nonHingeDeviation: Float ->
+ var (nonHingeRot, hingeAxisRot) = decompose(rotation, Vector3.NEG_X)
+ hingeAxisRot = constrain(hingeAxisRot, min, max, Vector3.NEG_X)
+ nonHingeRot = constrain(nonHingeRot, nonHingeDeviation)
+
+ nonHingeRot * hingeAxisRot
+ }
+
+ // Constraint function for CompleteConstraint
+ private val completeConstraint: ConstraintFunction = { rotation: Quaternion, _: Float, _: Float, _: Float ->
+ rotation
+ }
+ }
+}
diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt
index 773e11e282..626cf35cbe 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt
+++ b/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt
@@ -32,6 +32,11 @@ class TransformNode(val localRotation: Boolean) {
}
}
+ @ThreadSafe
+ fun updateThisNode() {
+ updateWorldTransforms()
+ }
+
@Synchronized
private fun updateWorldTransforms() {
worldTransform.set(localTransform)
diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt
index 9da33ef75f..2d26a447b7 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt
+++ b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt
@@ -1,7 +1,7 @@
package dev.slimevr.tracking.processor.config
import dev.slimevr.VRServer.Companion.instance
-import dev.slimevr.autobone.errors.BodyProportionError
+import dev.slimevr.autobone.AutoBone
import dev.slimevr.autobone.errors.BodyProportionError.Companion.proportionLimitMap
import dev.slimevr.config.ConfigManager
import dev.slimevr.tracking.processor.BoneType
@@ -245,6 +245,20 @@ class SkeletonConfigManager(
-getOffset(SkeletonConfigOffsets.FOOT_LENGTH),
)
+ BoneType.LEFT_UPPER_SHOULDER -> setNodeOffset(
+ nodeOffset,
+ 0f,
+ 0f,
+ 0f,
+ )
+
+ BoneType.RIGHT_UPPER_SHOULDER -> setNodeOffset(
+ nodeOffset,
+ 0f,
+ 0f,
+ 0f,
+ )
+
BoneType.LEFT_SHOULDER -> setNodeOffset(
nodeOffset,
-getOffset(SkeletonConfigOffsets.SHOULDERS_WIDTH) / 2f,
@@ -441,18 +455,11 @@ class SkeletonConfigManager(
resetValues()
}
- fun resetOffset(config: SkeletonConfigOffsets?) {
- if (config == null) {
- return
- }
-
+ fun resetOffset(config: SkeletonConfigOffsets) {
when (config) {
SkeletonConfigOffsets.UPPER_CHEST, SkeletonConfigOffsets.CHEST, SkeletonConfigOffsets.WAIST, SkeletonConfigOffsets.HIP, SkeletonConfigOffsets.UPPER_LEG, SkeletonConfigOffsets.LOWER_LEG -> {
- val height = (
- humanPoseManager!!.hmdHeight /
- BodyProportionError.eyeHeightToHeightRatio
- )
- if (height > 0.5f) { // Reset only if floor level seems right,
+ val height = humanPoseManager?.server?.configManager?.vrConfig?.skeleton?.userHeight ?: -1f
+ if (height > AutoBone.MIN_HEIGHT) { // Reset only if floor level seems right,
val proportionLimiter = proportionLimitMap[config]
if (proportionLimiter != null) {
setOffset(
diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java
index cffec046b0..d27021c159 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java
+++ b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java
@@ -15,7 +15,10 @@ public enum SkeletonConfigToggles {
VIVE_EMULATION(7, "Vive emulation", "viveEmulation", false),
TOE_SNAP(8, "Toe Snap", "toeSnap", false),
FOOT_PLANT(9, "Foot Plant", "footPlant", true),
- SELF_LOCALIZATION(10, "Self Localization", "selfLocalization", false),;
+ SELF_LOCALIZATION(10, "Self Localization", "selfLocalization", false),
+ USE_POSITION(11, "Use Position", "usePosition", true),
+ ENFORCE_CONSTRAINTS(12, "Enforce Constraints", "enforceConstraints", true),
+ CORRECT_CONSTRAINTS(13, "Correct Constraints", "correctConstraints", true),;
public static final SkeletonConfigToggles[] values = values();
private static final Map byStringVal = new HashMap<>();
diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt
index c500ea2632..728faedf1d 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt
+++ b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt
@@ -4,6 +4,8 @@ import com.jme3.math.FastMath
import dev.slimevr.VRServer
import dev.slimevr.tracking.processor.Bone
import dev.slimevr.tracking.processor.BoneType
+import dev.slimevr.tracking.processor.Constraint
+import dev.slimevr.tracking.processor.Constraint.Companion.ConstraintType
import dev.slimevr.tracking.processor.HumanPoseManager
import dev.slimevr.tracking.processor.config.SkeletonConfigToggles
import dev.slimevr.tracking.processor.config.SkeletonConfigValues
@@ -35,77 +37,79 @@ class HumanSkeleton(
val humanPoseManager: HumanPoseManager,
) {
// Upper body bones
- val headBone = Bone(BoneType.HEAD)
- val neckBone = Bone(BoneType.NECK)
- val upperChestBone = Bone(BoneType.UPPER_CHEST)
- val chestBone = Bone(BoneType.CHEST)
- val waistBone = Bone(BoneType.WAIST)
- val hipBone = Bone(BoneType.HIP)
+ val headBone = Bone(BoneType.HEAD, Constraint(ConstraintType.COMPLETE))
+ val neckBone = Bone(BoneType.NECK, Constraint(ConstraintType.COMPLETE))
+ val upperChestBone = Bone(BoneType.UPPER_CHEST, Constraint(ConstraintType.TWIST_SWING, 90f, 120f))
+ val chestBone = Bone(BoneType.CHEST, Constraint(ConstraintType.TWIST_SWING, 60f, 120f))
+ val waistBone = Bone(BoneType.WAIST, Constraint(ConstraintType.TWIST_SWING, 60f, 120f))
+ val hipBone = Bone(BoneType.HIP, Constraint(ConstraintType.TWIST_SWING, 60f, 120f))
// Lower body bones
- val leftHipBone = Bone(BoneType.LEFT_HIP)
- val rightHipBone = Bone(BoneType.RIGHT_HIP)
- val leftUpperLegBone = Bone(BoneType.LEFT_UPPER_LEG)
- val rightUpperLegBone = Bone(BoneType.RIGHT_UPPER_LEG)
- val leftLowerLegBone = Bone(BoneType.LEFT_LOWER_LEG)
- val rightLowerLegBone = Bone(BoneType.RIGHT_LOWER_LEG)
- val leftFootBone = Bone(BoneType.LEFT_FOOT)
- val rightFootBone = Bone(BoneType.RIGHT_FOOT)
+ val leftHipBone = Bone(BoneType.LEFT_HIP, Constraint(ConstraintType.TWIST_SWING, 0f, 15f))
+ val rightHipBone = Bone(BoneType.RIGHT_HIP, Constraint(ConstraintType.TWIST_SWING, 0f, 15f))
+ val leftUpperLegBone = Bone(BoneType.LEFT_UPPER_LEG, Constraint(ConstraintType.TWIST_SWING, 120f, 180f))
+ val rightUpperLegBone = Bone(BoneType.RIGHT_UPPER_LEG, Constraint(ConstraintType.TWIST_SWING, 120f, 180f))
+ val leftLowerLegBone = Bone(BoneType.LEFT_LOWER_LEG, Constraint(ConstraintType.LOOSE_HINGE, 180f, 0f, 50f))
+ val rightLowerLegBone = Bone(BoneType.RIGHT_LOWER_LEG, Constraint(ConstraintType.LOOSE_HINGE, 180f, 0f, 50f))
+ val leftFootBone = Bone(BoneType.LEFT_FOOT, Constraint(ConstraintType.TWIST_SWING, 60f, 60f))
+ val rightFootBone = Bone(BoneType.RIGHT_FOOT, Constraint(ConstraintType.TWIST_SWING, 60f, 60f))
// Arm bones
- val leftShoulderBone = Bone(BoneType.LEFT_SHOULDER)
- val rightShoulderBone = Bone(BoneType.RIGHT_SHOULDER)
- val leftUpperArmBone = Bone(BoneType.LEFT_UPPER_ARM)
- val rightUpperArmBone = Bone(BoneType.RIGHT_UPPER_ARM)
- val leftLowerArmBone = Bone(BoneType.LEFT_LOWER_ARM)
- val rightLowerArmBone = Bone(BoneType.RIGHT_LOWER_ARM)
- val leftHandBone = Bone(BoneType.LEFT_HAND)
- val rightHandBone = Bone(BoneType.RIGHT_HAND)
+ val leftUpperShoulderBone = Bone(BoneType.LEFT_UPPER_SHOULDER, Constraint(ConstraintType.COMPLETE))
+ val rightUpperShoulderBone = Bone(BoneType.RIGHT_UPPER_SHOULDER, Constraint(ConstraintType.COMPLETE))
+ val leftShoulderBone = Bone(BoneType.LEFT_SHOULDER, Constraint(ConstraintType.TWIST_SWING, 0f, 10f))
+ val rightShoulderBone = Bone(BoneType.RIGHT_SHOULDER, Constraint(ConstraintType.TWIST_SWING, 0f, 10f))
+ val leftUpperArmBone = Bone(BoneType.LEFT_UPPER_ARM, Constraint(ConstraintType.TWIST_SWING, 120f, 180f))
+ val rightUpperArmBone = Bone(BoneType.RIGHT_UPPER_ARM, Constraint(ConstraintType.TWIST_SWING, 120f, 180f))
+ val leftLowerArmBone = Bone(BoneType.LEFT_LOWER_ARM, Constraint(ConstraintType.LOOSE_HINGE, 0f, -180f, 40f))
+ val rightLowerArmBone = Bone(BoneType.RIGHT_LOWER_ARM, Constraint(ConstraintType.LOOSE_HINGE, 0f, -180f, 40f))
+ val leftHandBone = Bone(BoneType.LEFT_HAND, Constraint(ConstraintType.TWIST_SWING, 120f, 120f))
+ val rightHandBone = Bone(BoneType.RIGHT_HAND, Constraint(ConstraintType.TWIST_SWING, 120f, 120f))
// Finger bones
- val leftThumbMetacarpalBone = Bone(BoneType.LEFT_THUMB_METACARPAL)
- val leftThumbProximalBone = Bone(BoneType.LEFT_THUMB_PROXIMAL)
- val leftThumbDistalBone = Bone(BoneType.LEFT_THUMB_DISTAL)
- val leftIndexProximalBone = Bone(BoneType.LEFT_INDEX_PROXIMAL)
- val leftIndexIntermediateBone = Bone(BoneType.LEFT_INDEX_INTERMEDIATE)
- val leftIndexDistalBone = Bone(BoneType.LEFT_INDEX_DISTAL)
- val leftMiddleProximalBone = Bone(BoneType.LEFT_MIDDLE_PROXIMAL)
- val leftMiddleIntermediateBone = Bone(BoneType.LEFT_MIDDLE_INTERMEDIATE)
- val leftMiddleDistalBone = Bone(BoneType.LEFT_MIDDLE_DISTAL)
- val leftRingProximalBone = Bone(BoneType.LEFT_RING_PROXIMAL)
- val leftRingIntermediateBone = Bone(BoneType.LEFT_RING_INTERMEDIATE)
- val leftRingDistalBone = Bone(BoneType.LEFT_RING_DISTAL)
- val leftLittleProximalBone = Bone(BoneType.LEFT_LITTLE_PROXIMAL)
- val leftLittleIntermediateBone = Bone(BoneType.LEFT_LITTLE_INTERMEDIATE)
- val leftLittleDistalBone = Bone(BoneType.LEFT_LITTLE_DISTAL)
- val rightThumbMetacarpalBone = Bone(BoneType.RIGHT_THUMB_METACARPAL)
- val rightThumbProximalBone = Bone(BoneType.RIGHT_THUMB_PROXIMAL)
- val rightThumbDistalBone = Bone(BoneType.RIGHT_THUMB_DISTAL)
- val rightIndexProximalBone = Bone(BoneType.RIGHT_INDEX_PROXIMAL)
- val rightIndexIntermediateBone = Bone(BoneType.RIGHT_INDEX_INTERMEDIATE)
- val rightIndexDistalBone = Bone(BoneType.RIGHT_INDEX_DISTAL)
- val rightMiddleProximalBone = Bone(BoneType.RIGHT_MIDDLE_PROXIMAL)
- val rightMiddleIntermediateBone = Bone(BoneType.RIGHT_MIDDLE_INTERMEDIATE)
- val rightMiddleDistalBone = Bone(BoneType.RIGHT_MIDDLE_DISTAL)
- val rightRingProximalBone = Bone(BoneType.RIGHT_RING_PROXIMAL)
- val rightRingIntermediateBone = Bone(BoneType.RIGHT_RING_INTERMEDIATE)
- val rightRingDistalBone = Bone(BoneType.RIGHT_RING_DISTAL)
- val rightLittleProximalBone = Bone(BoneType.RIGHT_LITTLE_PROXIMAL)
- val rightLittleIntermediateBone = Bone(BoneType.RIGHT_LITTLE_INTERMEDIATE)
- val rightLittleDistalBone = Bone(BoneType.RIGHT_LITTLE_DISTAL)
+ val leftThumbMetacarpalBone = Bone(BoneType.LEFT_THUMB_METACARPAL, Constraint(ConstraintType.COMPLETE))
+ val leftThumbProximalBone = Bone(BoneType.LEFT_THUMB_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val leftThumbDistalBone = Bone(BoneType.LEFT_THUMB_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val leftIndexProximalBone = Bone(BoneType.LEFT_INDEX_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val leftIndexIntermediateBone = Bone(BoneType.LEFT_INDEX_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val leftIndexDistalBone = Bone(BoneType.LEFT_INDEX_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val leftMiddleProximalBone = Bone(BoneType.LEFT_MIDDLE_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val leftMiddleIntermediateBone = Bone(BoneType.LEFT_MIDDLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val leftMiddleDistalBone = Bone(BoneType.LEFT_MIDDLE_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val leftRingProximalBone = Bone(BoneType.LEFT_RING_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val leftRingIntermediateBone = Bone(BoneType.LEFT_RING_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val leftRingDistalBone = Bone(BoneType.LEFT_RING_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val leftLittleProximalBone = Bone(BoneType.LEFT_LITTLE_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val leftLittleIntermediateBone = Bone(BoneType.LEFT_LITTLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val leftLittleDistalBone = Bone(BoneType.LEFT_LITTLE_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val rightThumbMetacarpalBone = Bone(BoneType.RIGHT_THUMB_METACARPAL, Constraint(ConstraintType.COMPLETE))
+ val rightThumbProximalBone = Bone(BoneType.RIGHT_THUMB_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val rightThumbDistalBone = Bone(BoneType.RIGHT_THUMB_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val rightIndexProximalBone = Bone(BoneType.RIGHT_INDEX_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val rightIndexIntermediateBone = Bone(BoneType.RIGHT_INDEX_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val rightIndexDistalBone = Bone(BoneType.RIGHT_INDEX_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val rightMiddleProximalBone = Bone(BoneType.RIGHT_MIDDLE_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val rightMiddleIntermediateBone = Bone(BoneType.RIGHT_MIDDLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val rightMiddleDistalBone = Bone(BoneType.RIGHT_MIDDLE_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val rightRingProximalBone = Bone(BoneType.RIGHT_RING_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val rightRingIntermediateBone = Bone(BoneType.RIGHT_RING_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val rightRingDistalBone = Bone(BoneType.RIGHT_RING_DISTAL, Constraint(ConstraintType.COMPLETE))
+ val rightLittleProximalBone = Bone(BoneType.RIGHT_LITTLE_PROXIMAL, Constraint(ConstraintType.COMPLETE))
+ val rightLittleIntermediateBone = Bone(BoneType.RIGHT_LITTLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE))
+ val rightLittleDistalBone = Bone(BoneType.RIGHT_LITTLE_DISTAL, Constraint(ConstraintType.COMPLETE))
// Tracker bones
- val headTrackerBone = Bone(BoneType.HEAD_TRACKER)
- val chestTrackerBone = Bone(BoneType.CHEST_TRACKER)
- val hipTrackerBone = Bone(BoneType.HIP_TRACKER)
- val leftKneeTrackerBone = Bone(BoneType.LEFT_KNEE_TRACKER)
- val rightKneeTrackerBone = Bone(BoneType.RIGHT_KNEE_TRACKER)
- val leftFootTrackerBone = Bone(BoneType.LEFT_FOOT_TRACKER)
- val rightFootTrackerBone = Bone(BoneType.RIGHT_FOOT_TRACKER)
- val leftElbowTrackerBone = Bone(BoneType.LEFT_ELBOW_TRACKER)
- val rightElbowTrackerBone = Bone(BoneType.RIGHT_ELBOW_TRACKER)
- val leftHandTrackerBone = Bone(BoneType.LEFT_HAND_TRACKER)
- val rightHandTrackerBone = Bone(BoneType.RIGHT_HAND_TRACKER)
+ val headTrackerBone = Bone(BoneType.HEAD_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val chestTrackerBone = Bone(BoneType.CHEST_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val hipTrackerBone = Bone(BoneType.HIP_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val leftKneeTrackerBone = Bone(BoneType.LEFT_KNEE_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val rightKneeTrackerBone = Bone(BoneType.RIGHT_KNEE_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val leftFootTrackerBone = Bone(BoneType.LEFT_FOOT_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val rightFootTrackerBone = Bone(BoneType.RIGHT_FOOT_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val leftElbowTrackerBone = Bone(BoneType.LEFT_ELBOW_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val rightElbowTrackerBone = Bone(BoneType.RIGHT_ELBOW_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val leftHandTrackerBone = Bone(BoneType.LEFT_HAND_TRACKER, Constraint(ConstraintType.COMPLETE))
+ val rightHandTrackerBone = Bone(BoneType.RIGHT_HAND_TRACKER, Constraint(ConstraintType.COMPLETE))
// Buffers
var hasSpineTracker = false
@@ -190,6 +194,8 @@ class HumanSkeleton(
private var extendedPelvisModel = false
private var extendedKneeModel = false
private var forceArmsFromHMD = true
+ private var enforceConstraints = true
+ private var correctConstraints = true
// Ratios
private var waistFromChestHipAveraging = 0f
@@ -292,8 +298,10 @@ class HumanSkeleton(
}
// Shoulders
- neckBone.attachChild(leftShoulderBone)
- neckBone.attachChild(rightShoulderBone)
+ neckBone.attachChild(leftUpperShoulderBone)
+ neckBone.attachChild(rightUpperShoulderBone)
+ leftUpperShoulderBone.attachChild(leftShoulderBone)
+ rightUpperShoulderBone.attachChild(rightShoulderBone)
// Upper arm
leftShoulderBone.attachChild(leftUpperArmBone)
@@ -442,6 +450,9 @@ class HumanSkeleton(
// Update tap detection's trackers
tapDetectionManager.updateConfig(trackers)
+
+ // Update bones tracker field
+ refreshBoneTracker()
}
/**
@@ -500,6 +511,9 @@ class HumanSkeleton(
updateTransforms()
updateBones()
+ if (enforceConstraints) {
+ headBone.updateWithConstraints(correctConstraints)
+ }
updateComputedTrackers()
// Don't run post-processing if the tracking is paused
@@ -510,6 +524,15 @@ class HumanSkeleton(
viveEmulation.update()
}
+ /**
+ * Refresh the attachedTracker field in each bone
+ */
+ private fun refreshBoneTracker() {
+ for (bone in allHumanBones) {
+ bone.attachedTracker = getTrackerForBone(bone.boneType)
+ }
+ }
+
/**
* Update all the bones by updating the roots
*/
@@ -560,6 +583,7 @@ class HumanSkeleton(
// Left arm
updateArmTransforms(
isTrackingLeftArmFromController,
+ leftUpperShoulderBone,
leftShoulderBone,
leftUpperArmBone,
leftElbowTrackerBone,
@@ -575,6 +599,7 @@ class HumanSkeleton(
// Right arm
updateArmTransforms(
isTrackingRightArmFromController,
+ rightUpperShoulderBone,
rightShoulderBone,
rightUpperArmBone,
rightElbowTrackerBone,
@@ -925,6 +950,7 @@ class HumanSkeleton(
*/
private fun updateArmTransforms(
isTrackingFromController: Boolean,
+ upperShoulderBone: Bone,
shoulderBone: Bone,
upperArmBone: Bone,
elbowTrackerBone: Bone,
@@ -957,6 +983,7 @@ class HumanSkeleton(
// Get shoulder rotation
var armRot = shoulderTracker?.getRotation() ?: upperChestBone.getLocalRotation()
// Set shoulder rotation
+ upperShoulderBone.setRotation(upperChestBone.getLocalRotation())
shoulderBone.setRotation(armRot)
if (upperArmTracker != null || lowerArmTracker != null) {
@@ -1136,6 +1163,12 @@ class HumanSkeleton(
SkeletonConfigToggles.FOOT_PLANT -> legTweaks.footPlantEnabled = newValue
SkeletonConfigToggles.SELF_LOCALIZATION -> localizer.setEnabled(newValue)
+
+ SkeletonConfigToggles.USE_POSITION -> newValue
+
+ SkeletonConfigToggles.ENFORCE_CONSTRAINTS -> enforceConstraints = newValue
+
+ SkeletonConfigToggles.CORRECT_CONSTRAINTS -> correctConstraints = newValue
}
}
@@ -1217,6 +1250,8 @@ class HumanSkeleton(
BoneType.RIGHT_FOOT -> rightFootBone
BoneType.LEFT_FOOT_TRACKER -> leftFootTrackerBone
BoneType.RIGHT_FOOT_TRACKER -> rightFootTrackerBone
+ BoneType.LEFT_UPPER_SHOULDER -> leftUpperShoulderBone
+ BoneType.RIGHT_UPPER_SHOULDER -> rightUpperShoulderBone
BoneType.LEFT_SHOULDER -> leftShoulderBone
BoneType.RIGHT_SHOULDER -> rightShoulderBone
BoneType.LEFT_UPPER_ARM -> leftUpperArmBone
@@ -1261,6 +1296,30 @@ class HumanSkeleton(
BoneType.RIGHT_LITTLE_DISTAL -> rightLittleDistalBone
}
+ private fun getTrackerForBone(bone: BoneType?): Tracker? = when (bone) {
+ BoneType.HEAD -> headTracker
+ BoneType.NECK -> neckTracker
+ BoneType.UPPER_CHEST -> upperChestTracker
+ BoneType.CHEST -> chestTracker
+ BoneType.WAIST -> waistTracker
+ BoneType.HIP -> hipTracker
+ BoneType.LEFT_UPPER_LEG -> leftUpperLegTracker
+ BoneType.RIGHT_UPPER_LEG -> rightUpperLegTracker
+ BoneType.LEFT_LOWER_LEG -> leftLowerLegTracker
+ BoneType.RIGHT_LOWER_LEG -> rightLowerLegTracker
+ BoneType.LEFT_FOOT -> leftFootTracker
+ BoneType.RIGHT_FOOT -> rightFootTracker
+ BoneType.LEFT_SHOULDER -> leftShoulderTracker
+ BoneType.RIGHT_SHOULDER -> rightShoulderTracker
+ BoneType.LEFT_UPPER_ARM -> leftUpperArmTracker
+ BoneType.RIGHT_UPPER_ARM -> rightUpperArmTracker
+ BoneType.LEFT_LOWER_ARM -> leftLowerArmTracker
+ BoneType.RIGHT_LOWER_ARM -> rightLowerArmTracker
+ BoneType.LEFT_HAND -> leftHandTracker
+ BoneType.RIGHT_HAND -> rightHandTracker
+ else -> null
+ }
+
/**
* Returns an array of all the non-tracker bones.
*/
@@ -1280,6 +1339,8 @@ class HumanSkeleton(
rightLowerLegBone,
leftFootBone,
rightFootBone,
+ leftUpperShoulderBone,
+ rightUpperShoulderBone,
leftShoulderBone,
rightShoulderBone,
leftUpperArmBone,
@@ -1325,6 +1386,8 @@ class HumanSkeleton(
*/
private val allArmBones: Array
get() = arrayOf(
+ leftUpperShoulderBone,
+ rightUpperShoulderBone,
leftShoulderBone,
rightShoulderBone,
leftUpperArmBone,
diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt
index 1e33f1235d..2fb1f348a8 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt
+++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt
@@ -58,4 +58,9 @@ class TrackerFilteringHandler {
* Get the filtered rotation from the moving average (either prediction/smoothing or just >180 degs)
*/
fun getFilteredRotation() = movingAverage.filteredQuaternion
+
+ /**
+ * Get the impact filtering has on the rotation
+ */
+ fun getFilteringImpact(): Float = movingAverage.filteringImpact
}
diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt
index c90414dd65..1e21fbf8f3 100644
--- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt
+++ b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt
@@ -63,6 +63,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
var mountRotFix = Quaternion.IDENTITY
private set
private var yawFix = Quaternion.IDENTITY
+ private var constraintFix = Quaternion.IDENTITY
// Yaw reset smoothing vars
private var yawFixOld = Quaternion.IDENTITY
@@ -168,6 +169,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
rot = mountRotFix.inv() * (rot * mountRotFix)
rot *= tposeDownFix
rot = yawFix * rot
+ rot = constraintFix * rot
return rot
}
@@ -180,6 +182,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
rot = gyroFixNoMounting * rot
rot *= attachmentFixNoMounting
rot = yawFixZeroReference * rot
+ rot = constraintFix * rot
return rot
}
@@ -214,6 +217,8 @@ class TrackerResetsHandler(val tracker: Tracker) {
* 0). This allows the tracker to be strapped to body at any pitch and roll.
*/
fun resetFull(reference: Quaternion) {
+ constraintFix = Quaternion.IDENTITY
+
if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE) {
tracker.trackerFlexHandler.resetMin()
postProcessResetFull()
@@ -305,6 +310,8 @@ class TrackerResetsHandler(val tracker: Tracker) {
* position should be corrected in the source.
*/
fun resetYaw(reference: Quaternion) {
+ constraintFix = Quaternion.IDENTITY
+
if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE ||
tracker.trackerDataType == TrackerDataType.FLEX_ANGLE
) {
@@ -355,6 +362,8 @@ class TrackerResetsHandler(val tracker: Tracker) {
return
}
+ constraintFix = Quaternion.IDENTITY
+
// Get the current calibrated rotation
var rotBuf = adjustToDrift(tracker.getRawRotation() * mountingOrientation)
rotBuf = gyroFix * rotBuf
@@ -403,6 +412,13 @@ class TrackerResetsHandler(val tracker: Tracker) {
tracker.resetFilteringQuats()
}
+ /**
+ * Apply a corrective rotation to the gyroFix
+ */
+ fun updateConstraintFix(correctedRotation: Quaternion) {
+ constraintFix *= correctedRotation
+ }
+
fun clearMounting() {
mountRotFix = Quaternion.IDENTITY
}
diff --git a/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java b/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java
index 47a297bdc9..1c0adae841 100644
--- a/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java
+++ b/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java
@@ -28,7 +28,7 @@ public ConnectionContext getContext() {
@Override
public void send(ByteBuffer bytes) {
if (this.conn.isOpen())
- this.conn.send(bytes);
+ this.conn.send(bytes.slice());
}
@Override
diff --git a/server/core/src/main/java/io/eiren/util/logging/FileLogHandler.java b/server/core/src/main/java/io/eiren/util/logging/FileLogHandler.java
new file mode 100644
index 0000000000..eee14f4e28
--- /dev/null
+++ b/server/core/src/main/java/io/eiren/util/logging/FileLogHandler.java
@@ -0,0 +1,265 @@
+package io.eiren.util.logging;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.BufferedOutputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.logging.ErrorManager;
+import java.util.logging.LogRecord;
+import java.util.logging.StreamHandler;
+
+
+public class FileLogHandler extends StreamHandler {
+
+ protected class DatedLogFile implements Comparable {
+ public final File file;
+ public final LocalDateTime dateTime;
+ public final int count;
+
+ protected DatedLogFile(File file, LocalDateTime dateTime, int count) {
+ this.file = file;
+ this.dateTime = dateTime;
+ this.count = count;
+ }
+
+ @Override
+ public int compareTo(@NotNull DatedLogFile o) {
+ int dtCompare = dateTime.compareTo(o.dateTime);
+ return dtCompare != 0 ? dtCompare : Integer.compare(count, o.count);
+ }
+ }
+
+ private final char sectionSeparator = '_';
+ private final String logSuffix = ".log";
+
+ private final ArrayList logFiles;
+
+ private final Path path;
+ private final String logTag;
+ private final DateTimeFormatter dateFormat;
+ private final LocalDateTime dateTime;
+ private final String date;
+ private final int limit;
+ private final int maxCount;
+ private final long collectiveLimit;
+
+ private DataOutputStream curStream;
+ private int fileCount = 0;
+ private long collectiveSize = 0;
+
+ public FileLogHandler(
+ @NotNull Path path,
+ @NotNull String logTag,
+ @NotNull DateTimeFormatter dateFormat,
+ int limit,
+ int count
+ ) {
+ this(path, logTag, dateFormat, limit, count, -1);
+ }
+
+ public FileLogHandler(
+ @NotNull Path path,
+ @NotNull String logTag,
+ @NotNull DateTimeFormatter dateFormat,
+ int limit,
+ int count,
+ long collectiveLimit
+ ) {
+ this.path = path;
+ this.logTag = logTag;
+
+ this.dateFormat = dateFormat;
+ this.dateTime = LocalDateTime.now();
+ this.date = dateTime.format(dateFormat);
+
+ this.limit = limit;
+ this.maxCount = count;
+ this.collectiveLimit = collectiveLimit;
+
+ // Find old logs to manage
+ logFiles = findLogs(path);
+ if (collectiveLimit > 0) {
+ collectiveSize = sumFileSizes(logFiles);
+ }
+
+ // Create new log and delete over the count
+ newFile();
+ }
+
+ private DatedLogFile parseFileName(File file) {
+ String name = file.getName();
+
+ // Log name should have at least two separators, one integer, and at
+ // least one char for the datetime (4 chars)
+ if (
+ !name.startsWith(logTag)
+ || !name.endsWith(logSuffix)
+ || name.length() < (logTag.length() + logSuffix.length() + 4)
+ ) {
+ // Ignore non-matching files
+ return null;
+ }
+
+ int dateEnd = name.lastIndexOf(sectionSeparator);
+ if (dateEnd < 0) {
+ // Ignore non-matching files
+ return null;
+ }
+
+ try {
+ // Move past the tag, then between the two separators
+ String dateTimeStr = name.substring(logTag.length() + 1, dateEnd);
+ LocalDateTime dateTime = LocalDateTime.parse(dateTimeStr, dateFormat);
+
+ // Move past the date separator and behind the suffix
+ int logNum = Integer
+ .parseInt(name, dateEnd + 1, name.length() - logSuffix.length(), 10);
+
+ return new DatedLogFile(file, dateTime, logNum);
+ } catch (Exception e) {
+ // Unable to parse log file, probably not valid
+ return null;
+ }
+ }
+
+ private ArrayList findLogs(Path path) {
+ ArrayList logFiles = new ArrayList<>();
+
+ File[] files = path.toFile().listFiles();
+ if (files == null)
+ return logFiles;
+
+ // Find all parseable log files
+ for (File log : files) {
+ DatedLogFile parsedFile = parseFileName(log);
+ if (parsedFile != null) {
+ logFiles.add(parsedFile);
+ }
+ }
+
+ return logFiles;
+ }
+
+ private long sumFileSizes(ArrayList logFiles) {
+ long size = 0;
+ for (DatedLogFile log : logFiles) {
+ size += log.file.length();
+ }
+ return size;
+ }
+
+ private void deleteFile(File file) {
+ if (!file.delete()) {
+ file.deleteOnExit();
+ reportError(
+ "Failed to delete file, deleting on exit.",
+ null,
+ ErrorManager.GENERIC_FAILURE
+ );
+ }
+ }
+
+ private DatedLogFile getEarliestFile(ArrayList logFiles) {
+ DatedLogFile earliest = null;
+
+ for (DatedLogFile log : logFiles) {
+ if (earliest == null || log.compareTo(earliest) < 0) {
+ earliest = log;
+ }
+ }
+
+ return earliest;
+ }
+
+ private synchronized void deleteEarliestFile() {
+ DatedLogFile earliest = getEarliestFile(logFiles);
+ if (earliest != null) {
+ // If we have a collective limit, update the current size and clamp
+ if (collectiveLimit > 0) {
+ collectiveSize -= earliest.file.length();
+ if (collectiveSize < 0)
+ collectiveSize = 0;
+ }
+
+ logFiles.remove(earliest);
+ deleteFile(earliest.file);
+ }
+ }
+
+ private synchronized void newFile() {
+ // Clear the last log file
+ if (curStream != null) {
+ collectiveSize += curStream.size();
+ close();
+ }
+
+ if (maxCount > 0) {
+ // Delete files over the count
+ while (logFiles.size() >= maxCount) {
+ deleteEarliestFile();
+ }
+ }
+
+ if (collectiveLimit > 0) {
+ // Delete files over the collective size limit
+ while (!logFiles.isEmpty() && collectiveSize >= collectiveLimit) {
+ deleteEarliestFile();
+ }
+ }
+
+ try {
+ Path logPath = path
+ .resolve(
+ logTag
+ + sectionSeparator
+ + date
+ + sectionSeparator
+ + fileCount
+ + logSuffix
+ );
+ File newFile = logPath.toFile();
+
+ // Use DataOutputStream to count bytes written
+ curStream = new DataOutputStream(
+ new BufferedOutputStream(new FileOutputStream(newFile))
+ );
+ // Closes the last stream automatically if not already done
+ setOutputStream(curStream);
+
+ // Add log to the tracking list to be deleted if needed
+ logFiles.add(new DatedLogFile(newFile, dateTime, fileCount));
+ fileCount += 1;
+ } catch (FileNotFoundException e) {
+ reportError(null, e, ErrorManager.OPEN_FAILURE);
+ }
+ }
+
+ @Override
+ public synchronized void publish(LogRecord record) {
+ if (!isLoggable(record)) {
+ return;
+ }
+
+ super.publish(record);
+ flush();
+
+ if (collectiveLimit > 0) {
+ // Delete files over the collective size limit
+ while (!logFiles.isEmpty() && collectiveSize + curStream.size() >= collectiveLimit) {
+ deleteEarliestFile();
+ }
+ }
+
+ // If written above the log limit, make a new file
+ if (limit > 0 && curStream.size() >= limit) {
+ newFile();
+ }
+ }
+}
diff --git a/server/core/src/main/java/io/eiren/util/logging/LogManager.java b/server/core/src/main/java/io/eiren/util/logging/LogManager.java
index 948e21130d..237dd4a4e3 100644
--- a/server/core/src/main/java/io/eiren/util/logging/LogManager.java
+++ b/server/core/src/main/java/io/eiren/util/logging/LogManager.java
@@ -3,10 +3,9 @@
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
-import java.nio.file.Paths;
+import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.ConsoleHandler;
-import java.util.logging.FileHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -30,10 +29,15 @@ public static void initialize(File mainLogDir)
if (!mainLogDir.exists())
mainLogDir.mkdirs();
- String lastLogPattern = Paths.get(mainLogDir.getPath(), "log_last_%g.log").toString();
- FileHandler filehandler = new FileHandler(lastLogPattern, 25 * 1000000, 2);
- filehandler.setFormatter(loc);
- global.addHandler(filehandler);
+ FileLogHandler fileHandler = new FileLogHandler(
+ mainLogDir.toPath(),
+ "slimevr-server",
+ DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"),
+ 25 * 1000000,
+ 2
+ );
+ fileHandler.setFormatter(loc);
+ global.addHandler(fileHandler);
}
}
diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt
index e1719a53d0..675336e616 100644
--- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt
+++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt
@@ -5,10 +5,11 @@ package dev.slimevr.desktop
import dev.slimevr.Keybinding
import dev.slimevr.SLIMEVR_IDENTIFIER
import dev.slimevr.VRServer
-import dev.slimevr.bridge.ISteamVRBridge
+import dev.slimevr.bridge.Bridge
import dev.slimevr.desktop.firmware.DesktopSerialFlashingHandler
import dev.slimevr.desktop.platform.SteamVRBridge
import dev.slimevr.desktop.platform.linux.UnixSocketBridge
+import dev.slimevr.desktop.platform.linux.UnixSocketRpcBridge
import dev.slimevr.desktop.platform.windows.WindowsNamedPipeBridge
import dev.slimevr.desktop.serial.DesktopSerialHandler
import dev.slimevr.desktop.tracking.trackers.hid.TrackersHID
@@ -119,8 +120,7 @@ fun main(args: Array) {
val configDir = resolveConfig()
LogManager.info("Using config dir: $configDir")
val vrServer = VRServer(
- ::provideSteamVRBridge,
- ::provideFeederBridge,
+ ::provideBridges,
{ _ -> DesktopSerialHandler() },
{ _ -> DesktopSerialFlashingHandler() },
configPath = configDir,
@@ -151,90 +151,89 @@ fun main(args: Array) {
}
}
-fun provideSteamVRBridge(
+fun provideBridges(
server: VRServer,
computedTrackers: List,
-): ISteamVRBridge? {
- val driverBridge: SteamVRBridge?
- if (OperatingSystem.currentPlatform == OperatingSystem.WINDOWS) {
- // Create named pipe bridge for SteamVR driver
- driverBridge = WindowsNamedPipeBridge(
- server,
- "steamvr",
- "SteamVR Driver Bridge",
- """\\.\pipe\SlimeVRDriver""",
- computedTrackers,
- )
- } else if (OperatingSystem.currentPlatform == OperatingSystem.LINUX) {
- var linuxBridge: SteamVRBridge? = null
- try {
- linuxBridge = UnixSocketBridge(
- server,
- "steamvr",
- "SteamVR Driver Bridge",
- Paths.get(OperatingSystem.socketDirectory, "SlimeVRDriver")
- .toString(),
- computedTrackers,
- )
- } catch (ex: Exception) {
- LogManager.severe(
- "Failed to initiate Unix socket, disabling driver bridge...",
- ex,
- )
- }
- driverBridge = linuxBridge
- if (driverBridge != null) {
- // Close the named socket on shutdown, or otherwise it's not going to get removed
- Runtime.getRuntime().addShutdownHook(
- Thread {
- try {
- (driverBridge as? UnixSocketBridge)?.close()
- } catch (e: Exception) {
- throw RuntimeException(e)
- }
- },
- )
- }
- } else {
- driverBridge = null
- }
-
- return driverBridge
-}
-
-fun provideFeederBridge(
- server: VRServer,
-): ISteamVRBridge? {
- val feederBridge: SteamVRBridge?
+): Sequence = sequence {
when (OperatingSystem.currentPlatform) {
OperatingSystem.WINDOWS -> {
+ // Create named pipe bridge for SteamVR driver
+ yield(
+ WindowsNamedPipeBridge(
+ server,
+ "steamvr",
+ "SteamVR Driver Bridge",
+ """\\.\pipe\SlimeVRDriver""",
+ computedTrackers,
+ ),
+ )
+
// Create named pipe bridge for SteamVR input
- feederBridge = WindowsNamedPipeBridge(
- server,
- "steamvr_feeder",
- "SteamVR Feeder Bridge",
- """\\.\pipe\SlimeVRInput""",
- FastList(),
+ yield(
+ WindowsNamedPipeBridge(
+ server,
+ "steamvr_feeder",
+ "SteamVR Feeder Bridge",
+ """\\.\pipe\SlimeVRInput""",
+ FastList(),
+ ),
)
}
OperatingSystem.LINUX -> {
- feederBridge = UnixSocketBridge(
- server,
- "steamvr_feeder",
- "SteamVR Feeder Bridge",
- Paths.get(OperatingSystem.socketDirectory, "SlimeVRInput")
- .toString(),
- FastList(),
+ var linuxBridge: SteamVRBridge? = null
+ try {
+ linuxBridge = UnixSocketBridge(
+ server,
+ "steamvr",
+ "SteamVR Driver Bridge",
+ Paths.get(OperatingSystem.socketDirectory, "SlimeVRDriver")
+ .toString(),
+ computedTrackers,
+ )
+ } catch (ex: Exception) {
+ LogManager.severe(
+ "Failed to initiate Unix socket, disabling driver bridge...",
+ ex,
+ )
+ }
+ if (linuxBridge != null) {
+ // Close the named socket on shutdown, or otherwise it's not going to get removed
+ Runtime.getRuntime().addShutdownHook(
+ Thread {
+ try {
+ (linuxBridge as? UnixSocketBridge)?.close()
+ } catch (e: Exception) {
+ throw RuntimeException(e)
+ }
+ },
+ )
+ yield(linuxBridge)
+ }
+
+ yield(
+ UnixSocketBridge(
+ server,
+ "steamvr_feeder",
+ "SteamVR Feeder Bridge",
+ Paths.get(OperatingSystem.socketDirectory, "SlimeVRInput")
+ .toString(),
+ FastList(),
+ ),
)
- }
- else -> {
- feederBridge = null
+ yield(
+ UnixSocketRpcBridge(
+ server,
+ Paths.get(OperatingSystem.socketDirectory, "SlimeVRRpc")
+ .toString(),
+ computedTrackers,
+ ),
+ )
}
- }
- return feederBridge
+ else -> {}
+ }
}
const val CONFIG_FILENAME = "vrconfig.yml"
diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java
new file mode 100644
index 0000000000..45ccb81b0b
--- /dev/null
+++ b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java
@@ -0,0 +1,114 @@
+package dev.slimevr.desktop.platform.linux;
+
+import dev.slimevr.protocol.ConnectionContext;
+import dev.slimevr.protocol.GenericConnection;
+import io.eiren.util.logging.LogManager;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.SocketChannel;
+import java.util.UUID;
+
+
+public class UnixSocketConnection implements GenericConnection {
+ public final UUID id;
+ public final ConnectionContext context;
+ private final ByteBuffer dst = ByteBuffer.allocate(2048).order(ByteOrder.LITTLE_ENDIAN);
+ private final SocketChannel channel;
+ private int remainingBytes;
+
+ public UnixSocketConnection(SocketChannel channel) {
+ this.id = UUID.randomUUID();
+ this.context = new ConnectionContext();
+ this.channel = channel;
+ }
+
+ @Override
+ public UUID getConnectionId() {
+ return id;
+ }
+
+ @Override
+ public ConnectionContext getContext() {
+ return this.context;
+ }
+
+ public boolean isConnected() {
+ return this.channel.isConnected();
+ }
+
+ private void resetChannel() {
+ try {
+ this.channel.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public void send(ByteBuffer bytes) {
+ if (!this.channel.isConnected())
+ return;
+ try {
+ ByteBuffer[] src = new ByteBuffer[] {
+ ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN),
+ bytes.slice(),
+ };
+ src[0].putInt(src[1].remaining() + 4);
+ src[0].flip();
+ synchronized (this) {
+ while (src[1].hasRemaining()) {
+ this.channel.write(src);
+ }
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public ByteBuffer read() {
+ if (dst.position() < 4 || dst.position() < dst.getInt(0)) {
+ if (!this.channel.isConnected())
+ return null;
+ try {
+ int result = this.channel.read(dst);
+ if (result == -1) {
+ LogManager.info("[SolarXR Bridge] Reached end-of-stream on connection");
+ this.resetChannel();
+ return null;
+ }
+ if (dst.position() < 4) {
+ return null;
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ this.resetChannel();
+ return null;
+ }
+ }
+ int messageLength = dst.getInt(0);
+ if (messageLength > 1024) {
+ LogManager
+ .severe(
+ "[SolarXR Bridge] Buffer overflow on socket. Message length: " + messageLength
+ );
+ this.resetChannel();
+ return null;
+ }
+ if (dst.position() < messageLength) {
+ return null;
+ }
+ remainingBytes = dst.position() - messageLength;
+ dst.position(4);
+ dst.limit(messageLength);
+ return dst;
+ }
+
+ public void next() {
+ dst.position(dst.limit());
+ dst.limit(dst.limit() + remainingBytes);
+ dst.compact();
+ dst.limit(dst.capacity());
+ }
+}
diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java
new file mode 100644
index 0000000000..82f2901fa9
--- /dev/null
+++ b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java
@@ -0,0 +1,139 @@
+package dev.slimevr.desktop.platform.linux;
+
+import dev.slimevr.bridge.BridgeThread;
+import dev.slimevr.protocol.GenericConnection;
+import dev.slimevr.protocol.ProtocolAPI;
+import dev.slimevr.tracking.trackers.Tracker;
+import dev.slimevr.util.ann.VRServerThread;
+import dev.slimevr.VRServer;
+import io.eiren.util.logging.LogManager;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.StandardProtocolFamily;
+import java.net.UnixDomainSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.channels.SelectionKey;
+import java.nio.channels.Selector;
+import java.nio.channels.ServerSocketChannel;
+import java.nio.channels.SocketChannel;
+import java.util.List;
+
+
+public class UnixSocketRpcBridge implements dev.slimevr.bridge.Bridge,
+ dev.slimevr.protocol.ProtocolAPIServer, Runnable, AutoCloseable {
+ private final Thread runnerThread = new Thread(this, "Named socket thread");
+ private final String socketPath;
+ private final ProtocolAPI protocolAPI;
+ private final ServerSocketChannel socket;
+ private final Selector selector;
+
+ public UnixSocketRpcBridge(
+ VRServer server,
+ String socketPath,
+ List shareableTrackers
+ ) {
+ this.socketPath = socketPath;
+ this.protocolAPI = server.protocolAPI;
+ File socketFile = new File(socketPath);
+ if (socketFile.exists())
+ throw new RuntimeException(socketPath + " socket already exists.");
+ socketFile.deleteOnExit();
+ try {
+ socket = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
+ selector = Selector.open();
+ } catch (IOException e) {
+ e.printStackTrace();
+ throw new RuntimeException("Socket open failed.");
+ }
+
+ server.protocolAPI.registerAPIServer(this);
+ }
+
+ @VRServerThread
+ private void disconnected() {
+ }
+
+ @Override
+ @VRServerThread
+ public void dataRead() {
+ }
+
+ @Override
+ @VRServerThread
+ public void dataWrite() {
+ }
+
+ @Override
+ @VRServerThread
+ public void addSharedTracker(Tracker tracker) {
+ }
+
+ @Override
+ @VRServerThread
+ public void removeSharedTracker(Tracker tracker) {
+ }
+
+ @Override
+ @VRServerThread
+ public void startBridge() {
+ this.runnerThread.start();
+ }
+
+ @Override
+ @BridgeThread
+ public void run() {
+ try {
+ this.socket.bind(UnixDomainSocketAddress.of(this.socketPath));
+ this.socket.configureBlocking(false);
+ this.socket.register(this.selector, SelectionKey.OP_ACCEPT);
+ LogManager.info("[SolarXR Bridge] Socket " + this.socketPath + " created");
+ while (this.socket.isOpen()) {
+ this.selector.select(0);
+ for (SelectionKey key : this.selector.selectedKeys()) {
+ UnixSocketConnection conn = (UnixSocketConnection) key.attachment();
+ if (conn != null) {
+ for (ByteBuffer message; (message = conn.read()) != null; conn.next())
+ this.protocolAPI.onMessage(conn, message);
+ } else
+ for (SocketChannel channel; (channel = socket.accept()) != null;) {
+ channel.configureBlocking(false);
+ channel
+ .register(
+ this.selector,
+ SelectionKey.OP_READ,
+ new UnixSocketConnection(channel)
+ );
+ LogManager
+ .info(
+ "[SolarXR Bridge] Connected to "
+ + channel.getRemoteAddress().toString()
+ );
+ }
+ }
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ this.socket.close();
+ this.selector.close();
+ }
+
+ @Override
+ public boolean isConnected() {
+ return this.selector.keys().stream().anyMatch(key -> key.attachment() != null);
+ }
+
+ @Override
+ public java.util.stream.Stream getAPIConnections() {
+ return this.selector
+ .keys()
+ .stream()
+ .map(key -> (GenericConnection) key.attachment())
+ .filter(conn -> conn != null);
+ }
+}
diff --git a/solarxr-protocol b/solarxr-protocol
index aa69fb6e56..796c58e147 160000
--- a/solarxr-protocol
+++ b/solarxr-protocol
@@ -1 +1 @@
-Subproject commit aa69fb6e56b88a206010d1cb75bfa0edde5b4582
+Subproject commit 796c58e147c03faa1c662fd9c1734fb9536d1d4b