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 && ( + + )}
-
+
+ {doubleStep !== undefined && ( + + )}
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')} -
-
-
- -
-
-
- +
+ +
+
- { - 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 + + +
+
+ +
+
+ )} + {!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 + + +
+
-
-
+ )}
{!state.alonePage && ( @@ -280,15 +353,33 @@ export function ProportionsChoose() { {l10n.getString('onboarding-previous_step')} )} + {state.alonePage && ( + + )} + { + resetAll(); + setShowProportionWarning(false); + }} + onClose={() => setShowProportionWarning(false)} + isOpen={showProportionWarning} + > 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 && ( + + )} + {fetchHeight && ( + + )} + + {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 */} + {/*
+ Reset position +
*/} +
+ +
+ + + +
+
+ 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' + )} - - -
- - - {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 && ( + + )} + {fetchHeight && ( + )} - + + {l10n.getString( + 'onboarding-automatic_proportions-check_height-hmd_height2' + )} + + + {hmdHeight === null + ? l10n.getString( + 'onboarding-automatic_proportions-check_height-unknown' + ) + : mFormat.format(hmdHeight)} + +
-
- - isNaN(value) - ? l10n.getString( - 'onboarding-automatic_proportions-check_height-unknown' - ) - : mFormat.format(value) - } - min={MIN_HEIGHT} - max={4} - step={0.01} - disabled={true} +
+ Reset position - +
@@ -146,8 +148,8 @@ export function CheckHeight({ +
+
+
+ + ); +} 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' && ( + + )} +
+ +
+ ); +} 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! + +
+ )} +
+
+ + isNaN(value) + ? l10n.getString( + 'onboarding-scaled_proportions-manual_height-unknown' + ) + : mFormat.format(value) + } + min={MIN_HEIGHT} + max={4} + step={0.01} + showButtonWithNumber + doubleStep={0.1} + /> + +
+
+ +
+ + +
+
+ + ); +} 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' + )} + +
+
+ +
+
+ + +
+
+
+ + ); +} 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