Expand skeleton height config (#1156)

Co-authored-by: Uriel <imurx@proton.me>
This commit is contained in:
Butterscotch!
2025-01-23 11:21:38 -05:00
committed by GitHub
parent 2c49d1dc65
commit 4ad9d5cfca
27 changed files with 1217 additions and 341 deletions

View File

@@ -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 =
<b>Warning:</b> 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
@@ -962,6 +966,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.
<b>This requires having your headset (HMD) connected to SlimeVR and on your head!</b>
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 +994,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 +1008,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 <u>upright</u> 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 <u>upright</u> 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 +1057,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 +1073,26 @@ onboarding-automatic_proportions-error_modal-v2 =
Please <docs>check the docs</docs> or join our <discord>Discord</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 }.
<b>Please redo the measurements and ensure they are correct.</b>
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. <b>Proceed at your own risk or check the docs!</b>
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

View File

@@ -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={<ManualProportionsPage />}
/>
<Route
path="body-proportions/scaled"
element={<ScaledProportionsPage />}
/>
<Route path="done" element={<DonePage />} />
</Route>
<Route path="*" element={<TopBar></TopBar>}></Route>

View File

@@ -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 (
<Controller
control={control}
@@ -35,7 +61,19 @@ export function NumberSelector({
<div className="flex flex-col gap-1 w-full">
<Typography bold>{label}</Typography>
<div className="flex gap-5 bg-background-60 p-2 rounded-lg">
<div className="flex">
<div className="flex gap-1">
{doubleStep !== undefined && (
<Button
variant="tertiary"
rounded
onClick={() => onChange(doubleStepFn(value, false))}
disabled={doubleStepFn(value, false) < min || disabled}
>
{showButtonWithNumber
? decimalFormat.format(-doubleStep)
: '--'}
</Button>
)}
<Button
variant="tertiary"
rounded
@@ -48,7 +86,7 @@ export function NumberSelector({
<div className="flex flex-grow justify-center items-center w-10 text-standard">
{valueLabelFormat ? valueLabelFormat(value) : value}
</div>
<div className="flex">
<div className="flex gap-1">
<Button
variant="tertiary"
rounded
@@ -57,6 +95,18 @@ export function NumberSelector({
>
+
</Button>
{doubleStep !== undefined && (
<Button
variant="tertiary"
rounded
onClick={() => onChange(doubleStepFn(value, true))}
disabled={doubleStepFn(value, true) > max || disabled}
>
{showButtonWithNumber
? decimalFormat.format(doubleStep)
: '++'}
</Button>
)}
</div>
</div>
</div>

View File

@@ -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<HTMLDivElement | null>(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({
</div>
</div>
<div className="flex justify-center items-center gap-2">
{Array.from({ length: stepsContainers }).map((_, index) => (
{Array.from({ length: steps.length }).map((_, index) => (
<div key={index} className="flex items-center gap-2">
{index !== 0 && (
<div className="w-5 h-1 bg-background-50 rounded-full"></div>

View File

@@ -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 (
<AutoboneContextC.Provider value={context}>
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center overflow-y-auto overflow-x-hidden relative px-4 pb-4">
<div className="flex flex-col w-full xs:h-full xs:justify-center max-w-3xl gap-5">
<div className="flex flex-col max-w-lg gap-3">
<Typography variant="main-title">
{l10n.getString('onboarding-automatic_proportions-title')}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString('onboarding-automatic_proportions-description')}
<HeightContextC.Provider value={heightContext}>
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center overflow-y-auto overflow-x-hidden relative px-4 pb-4">
<div className="flex flex-col w-full xs:h-full xs:justify-center max-w-3xl gap-5">
<div className="flex flex-col max-w-lg gap-3">
<Typography variant="main-title">
{l10n.getString('onboarding-automatic_proportions-title')}
</Typography>
</div>
</div>
<div className="flex">
<StepperSlider
variant={state.alonePage ? 'alone' : 'onboarding'}
steps={[
{ type: 'numbered', component: PutTrackersOnStep },
{ type: 'numbered', component: PreparationStep },
{ type: 'numbered', component: RequirementsStep },
{ type: 'numbered', component: CheckHeight },
{ type: 'numbered', component: StartRecording },
{ type: 'fullsize', component: Recording },
{ type: 'numbered', component: VerifyResultsStep },
{ type: 'fullsize', component: DoneStep },
]}
></StepperSlider>
</div>
</div>
<div className="w-full pb-4 flex flex-row mobile:justify-center">
<Button
variant="secondary"
onClick={() => setShowWarning(true)}
disabled={isCounting}
>
<div className="relative">
<div className="opacity-0 h-0">
{l10n.getString('reset-reset_all')}
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_proportions-description'
)}
</Typography>
</div>
{!isCounting ? l10n.getString('reset-reset_all') : timer}
</div>
</Button>
<div className="flex">
<StepperSlider
variant={state.alonePage ? 'alone' : 'onboarding'}
steps={[
{ type: 'numbered', component: PutTrackersOnStep },
{ type: 'numbered', component: RequirementsStep },
{ type: 'numbered', component: CheckHeightStep },
{ type: 'numbered', component: CheckFloorHeightStep },
{ type: 'numbered', component: PreparationStep },
{ type: 'numbered', component: StartRecording },
{ type: 'fullsize', component: Recording },
{ type: 'numbered', component: VerifyResultsStep },
{ type: 'fullsize', component: DoneStep },
]}
></StepperSlider>
</div>
</div>
</div>
<ProportionsResetModal
accept={() => {
startCountdown();
setShowWarning(false);
}}
onClose={() => setShowWarning(false)}
isOpen={showWarning}
></ProportionsResetModal>
</div>
</HeightContextC.Provider>
</AutoboneContextC.Provider>
);
}

View File

@@ -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 (
<>
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center mobile:overflow-y-auto relative px-4 pb-4">
@@ -217,62 +230,122 @@ export function ProportionsChoose() {
</Button>
</div>
</div>
<div
className={classNames(
'rounded-lg p-4 flex flex-row relative',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<img
onMouseEnter={() => setAnimated(() => true)}
onAnimationEnd={() => setAnimated(() => false)}
src="/images/slimetower.webp"
className={classNames(
'absolute w-[100px] -right-2 -top-24',
animated && 'animate-[bounce_1s_1]'
)}
></img>
<div>
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-choose_proportions-auto_proportions'
{state.alonePage && (
<div
className={classNames(
'rounded-lg p-4 flex flex-row relative',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<img
onMouseEnter={() => setAnimated(() => true)}
onAnimationEnd={() => setAnimated(() => false)}
src="/images/slimetower.webp"
className={classNames(
'absolute w-[100px] -right-2 -top-24',
animated && 'animate-[bounce_1s_1]'
)}
</Typography>
<Typography variant="vr-accessible" italic>
{l10n.getString(
'onboarding-choose_proportions-auto_proportions-subtitle'
)}
</Typography>
</div>
<div>
<Localized
id="onboarding-choose_proportions-auto_proportions-descriptionv3"
elems={{ b: <b></b> }}
>
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
Description for autobone
></img>
<div>
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-choose_proportions-auto_proportions'
)}
</Typography>
</Localized>
<Typography variant="vr-accessible" italic>
{l10n.getString(
'onboarding-choose_proportions-auto_proportions-subtitle'
)}
</Typography>
</div>
<div>
<Localized
id="onboarding-choose_proportions-auto_proportions-descriptionv3"
elems={{ b: <b></b> }}
>
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
Description for autobone
</Typography>
</Localized>
</div>
</div>
<Button
variant="primary"
// Check if we are in dev mode and just let it be used
disabled={beneathFloor && import.meta.env.PROD}
to="/onboarding/body-proportions/auto"
className="self-start mt-auto"
state={{ alonePage: state.alonePage }}
>
{l10n.getString('onboarding-manual_proportions-auto')}
</Button>
</div>
<Button
variant="primary"
// Check if we are in dev mode and just let it be used
disabled={beneathFloor && import.meta.env.PROD}
to="/onboarding/body-proportions/auto"
className="self-start mt-auto"
state={{ alonePage: state.alonePage }}
>
{l10n.getString('onboarding-manual_proportions-auto')}
</Button>
</div>
</div>
)}
{!state.alonePage && (
<div
className={classNames(
'rounded-lg p-4 flex flex-row relative',
!state.alonePage && 'bg-background-70',
state.alonePage && 'bg-background-60'
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<img
onMouseEnter={() => setAnimated(() => true)}
onAnimationEnd={() => setAnimated(() => false)}
src="/images/slimetower.webp"
className={classNames(
'absolute w-[100px] -right-2 -top-24',
animated && 'animate-[bounce_1s_1]'
)}
></img>
<div>
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-choose_proportions-scaled_proportions'
)}
</Typography>
<Typography variant="vr-accessible" italic>
{l10n.getString(
'onboarding-choose_proportions-scaled_proportions-subtitle'
)}
</Typography>
</div>
<div>
<Localized
id="onboarding-choose_proportions-scaled_proportions-description"
elems={{ b: <b></b> }}
>
<Typography
color="secondary"
whitespace="whitespace-pre-line"
>
Description for scaled proportions
</Typography>
</Localized>
</div>
</div>
<Button
variant="primary"
to="/onboarding/body-proportions/scaled"
className="self-start mt-auto"
state={{ alonePage: state.alonePage }}
>
{l10n.getString(
'onboarding-choose_proportions-scaled_proportions-button'
)}
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-row gap-3">
{!state.alonePage && (
@@ -280,15 +353,33 @@ export function ProportionsChoose() {
{l10n.getString('onboarding-previous_step')}
</Button>
)}
{state.alonePage && (
<Button
variant="secondary"
onClick={() => setShowProportionWarning(true)}
>
{l10n.getString('reset-reset_all')}
</Button>
)}
<ProportionsResetModal
accept={() => {
resetAll();
setShowProportionWarning(false);
}}
onClose={() => setShowProportionWarning(false)}
isOpen={showProportionWarning}
></ProportionsResetModal>
<Button
variant={!state.alonePage ? 'secondary' : 'tertiary'}
className="ml-auto"
onClick={() =>
onClick={() => {
exporting.current = true;
sendRPCPacket(
RpcMessage.SkeletonConfigRequest,
new SkeletonConfigRequestT()
)
}
);
}}
>
{l10n.getString('onboarding-choose_proportions-export')}
</Button>

View File

@@ -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 (
<BaseModal
@@ -35,7 +52,14 @@ export function ProportionsResetModal({
>
<div className="flex w-full h-full flex-col ">
<div className="flex flex-col flex-grow items-center gap-3">
<Localized id="reset-reset_all_warning" elems={{ b: <b></b> }}>
<Localized
id={
usingDefaultHeight
? 'reset-reset_all_warning_default'
: 'reset-reset_all_warning'
}
elems={{ b: <b></b> }}
>
<WarningBox>
<b>Warning:</b> This will reset your proportions to being just
based on your height.

View File

@@ -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 (
<HeightContextC.Provider value={heightContext}>
<div className="flex flex-col gap-5 h-full items-center w-full xs:justify-center overflow-y-auto overflow-x-hidden relative px-4 pb-4">
<div className="flex flex-col w-full xs:h-full xs:justify-center max-w-3xl gap-5">
<div className="flex flex-col max-w-lg gap-3">
<Typography variant="main-title">
{l10n.getString('onboarding-scaled_proportions-title')}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString('onboarding-scaled_proportions-description')}
</Typography>
</div>
</div>
<div className="flex">
<StepperSlider
variant={state.alonePage ? 'alone' : 'onboarding'}
steps={
!hmdTracker
? [
{ type: 'numbered', component: ManualHeightStep },
{ type: 'numbered', component: ResetProportionsStep },
{ type: 'fullsize', component: DoneStep },
]
: [
{ type: 'numbered', component: CheckHeightStep },
{ type: 'numbered', component: CheckFloorHeightStep },
{ type: 'numbered', component: ResetProportionsStep },
{ type: 'fullsize', component: DoneStep },
]
}
back={() =>
navigate('/onboarding/body-proportions/choose', { state })
}
></StepperSlider>
</div>
</div>
</div>
</HeightContextC.Provider>
);
}

View File

@@ -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 (
<>
<div className="flex flex-col">
<div className="flex flex-grow">
<div className="flex flex-grow flex-col gap-4">
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-automatic_proportions-check_floor_height-title'
)}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_proportions-check_floor_height-description'
)}
</Typography>
<Localized
id="onboarding-automatic_proportions-check_floor_height-calculation_warning"
elems={{ u: <span className="underline"></span> }}
>
<Typography color="secondary" bold>
Press the button to get your height!
</Typography>
</Localized>
</div>
<div className="flex flex-grow items-center justify-center">
<div className="flex flex-col gap-3 items-center">
{!fetchHeight && (
<Button
variant="primary"
onClick={() => {
setFloorHeight(null);
setFetchHeight(true);
}}
>
<Typography textAlign="text-center">
{l10n.getString(
floorHeight !== null
? 'onboarding-automatic_proportions-check_floor_height-measure-reset'
: 'onboarding-automatic_proportions-check_floor_height-measure-start'
)}
</Typography>
</Button>
)}
{fetchHeight && (
<Button
variant="primary"
onClick={() => {
setFetchHeight(false);
}}
>
<Typography textAlign="text-center">
{l10n.getString(
'onboarding-automatic_proportions-check_floor_height-measure-stop'
)}
</Typography>
</Button>
)}
<Typography>
{l10n.getString(
'onboarding-automatic_proportions-check_floor_height-floor_height'
)}
</Typography>
<Typography
color={fetchHeight ? 'text-status-success' : undefined}
>
{floorHeight === null
? l10n.getString(
'onboarding-automatic_proportions-check_height-unknown'
)
: mFormat.format(floorHeight)}
</Typography>
</div>
</div>
</div>
{/* TODO: Get image of person putting controller in floor */}
{/* <div className="self-center">
<img
src="/images/front-standing-pose.webp"
width={isMobile ? 400 : 300}
alt="Reset position"
/>
</div> */}
</div>
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
onClick={prevStep}
>
{l10n.getString('onboarding-automatic_proportions-prev_step')}
</Button>
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
disabled={hmdHeight === null || fetchHeight}
onClick={() => {
if (!validateHeight(hmdHeight, 0)) {
setOpen(true);
return;
}
setFloorHeight(0);
const settingsRequest = new ChangeSettingsRequestT();
settingsRequest.modelSettings = new ModelSettingsT(
null,
null,
null,
new SkeletonHeightT(hmdHeight, 0)
);
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest);
nextStep();
}}
>
{l10n.getString(
'onboarding-automatic_proportions-check_floor_height-skip_step'
)}
</Button>
<Button
variant="primary"
onClick={() => {
if (!validateHeight(hmdHeight, floorHeight)) {
setOpen(true);
return;
}
const settingsRequest = new ChangeSettingsRequestT();
settingsRequest.modelSettings = new ModelSettingsT(
null,
null,
null,
new SkeletonHeightT(hmdHeight, floorHeight)
);
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest);
nextStep();
}}
disabled={floorHeight === null || hmdHeight === null || fetchHeight}
>
{l10n.getString(
'onboarding-automatic_proportions-check_floor_height-next_step'
)}
</Button>
</div>
</div>
<TooSmolModal isOpen={isOpen} onClose={() => setOpen(false)} />
</>
);
}

View File

@@ -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<HeightForm>();
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 (
<>
<div className="flex flex-col flex-grow">
<div className="flex flex-grow flex-col gap-4">
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-automatic_proportions-check_height-title'
)}
</Typography>
<div>
<Typography color="secondary">
<div className="flex gap-2 flex-grow">
<div className="flex flex-grow flex-col gap-4">
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-automatic_proportions-check_height-description'
'onboarding-automatic_proportions-check_height-title-v2'
)}
</Typography>
<Localized
id="onboarding-automatic_proportions-check_height-calculation_warning"
elems={{ u: <span className="underline"></span> }}
>
<Typography color="secondary" bold>
Press the button to get your height!
</Typography>
</Localized>
<div className="flex flex-row items-center mt-2 gap-2 mobile:flex-col">
<Button
variant="primary"
onClick={startCountdown}
disabled={isCounting}
>
{isCounting
? sFormat.format(timer, 'second')
: l10n.getString(
'onboarding-automatic_proportions-check_height-fetch_height'
)}
</Button>
<TipBox className="break-words">
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_proportions-check_height-guardian_tip'
'onboarding-automatic_proportions-check_height-description-v2'
)}
</TipBox>
</Typography>
<Localized
id="onboarding-automatic_proportions-check_height-calculation_warning-v2"
elems={{ u: <span className="underline"></span> }}
>
<Typography color="secondary" bold>
Press the button to get your height!
</Typography>
</Localized>
<div className="flex flex-row items-center mt-2 gap-2 mobile:flex-col">
<TipBox className="break-words">
{l10n.getString(
'onboarding-automatic_proportions-check_height-guardian_tip'
)}
</TipBox>
</div>
</div>
<div className="flex flex-grow items-center justify-center">
<div className="flex flex-col gap-3 items-center">
{!fetchHeight && (
<Button
variant="primary"
onClick={() => {
setHmdHeight(null);
setFetchHeight(true);
}}
>
<Typography textAlign="text-center">
{l10n.getString(
hmdHeight !== null
? 'onboarding-automatic_proportions-check_height-measure-reset'
: 'onboarding-automatic_proportions-check_height-measure-start'
)}
</Typography>
</Button>
)}
{fetchHeight && (
<Button
variant="primary"
onClick={() => {
setFetchHeight(false);
}}
>
<Typography textAlign="text-center">
{l10n.getString(
'onboarding-automatic_proportions-check_height-measure-stop'
)}
</Typography>
</Button>
)}
<Typography>
{l10n.getString(
'onboarding-automatic_proportions-check_height-hmd_height2'
)}
</Typography>
<Typography
color={fetchHeight ? 'text-status-success' : undefined}
>
{hmdHeight === null
? l10n.getString(
'onboarding-automatic_proportions-check_height-unknown'
)
: mFormat.format(hmdHeight)}
</Typography>
</div>
</div>
</div>
<form className="flex flex-col self-center items-center justify-center">
<NumberSelector
control={control}
name="hmdHeight"
label={l10n.getString(
'onboarding-automatic_proportions-check_height-hmd_height1'
)}
valueLabelFormat={(value) =>
isNaN(value)
? l10n.getString(
'onboarding-automatic_proportions-check_height-unknown'
)
: mFormat.format(value)
}
min={MIN_HEIGHT}
max={4}
step={0.01}
disabled={true}
<div className="self-center">
<img
src="/images/front-standing-pose.webp"
className="mobile:w-[150px] min-w-[120px] w-[34vh]"
alt="Reset position"
/>
</form>
</div>
</div>
<div className="flex gap-3 mobile:justify-between">
@@ -146,8 +148,8 @@ export function CheckHeight({
</Button>
<Button
variant="primary"
onClick={handleSubmit(onSubmit)}
disabled={!fetchedHeight}
onClick={nextStep}
disabled={hmdHeight === null || fetchHeight}
>
{l10n.getString(
'onboarding-automatic_proportions-check_height-next_step'

View File

@@ -41,7 +41,7 @@ export function PreparationStep({
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-12">
<img
src="/images/reset-pose.webp"
width={100}
width={50}
alt="Reset position"
/>
</div>

View File

@@ -0,0 +1,73 @@
import { BaseModal } from '@/components/commons/BaseModal';
import { Button } from '@/components/commons/Button';
import { WarningBox } from '@/components/commons/TipBox';
import { useHeightContext } from '@/hooks/height';
import { useLocaleConfig } from '@/i18n/config';
import { Localized, useLocalization } from '@fluent/react';
import { useMemo } from 'react';
import { MIN_HEIGHT } from '@/components/onboarding/pages/body-proportions/ProportionsChoose';
export function TooSmolModal({
isOpen = true,
onClose,
...props
}: {
/**
* Is the parent/sibling component opened?
*/
isOpen: boolean;
/**
* Function to trigger when the warning hasn't been accepted
*/
onClose: () => void;
} & ReactModal.Props) {
const { l10n } = useLocalization();
const { hmdHeight, floorHeight } = useHeightContext();
const { currentLocales } = useLocaleConfig();
const mFormat = useMemo(
() =>
new Intl.NumberFormat(currentLocales, {
style: 'unit',
unit: 'meter',
maximumFractionDigits: 2,
}),
[currentLocales]
);
return (
<BaseModal
isOpen={isOpen}
shouldCloseOnOverlayClick
shouldCloseOnEsc
onRequestClose={onClose}
className={props.className}
overlayClassName={props.overlayClassName}
>
<div className="flex w-full h-full flex-col ">
<div className="flex flex-col flex-grow items-center gap-3">
<Localized
id="onboarding-automatic_proportions-smol_warning"
elems={{ b: <b></b> }}
vars={{
height: mFormat.format(hmdHeight ?? 0 - (floorHeight ?? 0)),
minHeight: mFormat.format(MIN_HEIGHT),
}}
>
<WarningBox whitespace>
<b>Warning:</b> You are too smol to continue
</WarningBox>
</Localized>
<div className="flex flex-col gap-3 pt-5 place-content-center">
<Button variant="primary" onClick={onClose}>
{l10n.getString(
'onboarding-automatic_proportions-smol_warning-cancel'
)}
</Button>
</div>
</div>
</div>
</BaseModal>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center w-full justify-center gap-5">
<div className="flex gap-2 flex-col justify-center items-center">
<Typography variant="section-title">
{l10n.getString('onboarding-scaled_proportions-done-title')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-scaled_proportions-done-description')}
</Typography>
</div>
<div className="flex gap-3">
{variant === 'onboarding' && (
<Button variant="primary" to="/onboarding/done">
{l10n.getString('onboarding-continue')}
</Button>
)}
</div>
<SkeletonVisualizerWidget />
</div>
);
}

View File

@@ -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<HeightForm>({
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 (
<>
<div className="flex flex-col flex-grow">
<div className="flex gap-2 flex-grow">
<div className="flex flex-grow flex-col gap-4">
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-scaled_proportions-manual_height-title'
)}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-scaled_proportions-manual_height-description'
)}
</Typography>
{/* <Localized
id="onboarding-scaled_proportions-manual_height-warning"
elems={{ u: <span className="underline"></span> }}
>
<Typography color="secondary" bold>
Input your height manually!
</Typography>
</Localized> */}
{missingSteamConnection && (
<div className="flex flex-row items-center mt-2 gap-2 mobile:flex-col">
<Localized
id="onboarding-scaled_proportions-manual_height-missing_steamvr"
elems={{ b: <b></b> }}
// TODO: Add link to docs!
>
<WarningBox>You don't have SteamVR connected!</WarningBox>
</Localized>
</div>
)}
</div>
<form className="flex flex-col self-center items-center justify-center">
<NumberSelector
control={control}
name="height"
label={l10n.getString(
'onboarding-scaled_proportions-manual_height-height'
)}
valueLabelFormat={(value) =>
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}
/>
</form>
</div>
</div>
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
onClick={prevStep}
>
{l10n.getString('onboarding-automatic_proportions-prev_step')}
</Button>
<Button
variant="primary"
onClick={() => {
const settingsRequest = new ChangeSettingsRequestT();
settingsRequest.modelSettings = new ModelSettingsT(
null,
null,
null,
new SkeletonHeightT(hmdHeight, 0)
);
sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest);
nextStep();
}}
>
{l10n.getString(
'onboarding-scaled_proportions-manual_height-next_step'
)}
</Button>
</div>
</div>
</>
);
}

View File

@@ -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 (
<>
<div className="flex flex-col flex-grow">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<Typography variant="main-title" bold>
{l10n.getString(
'onboarding-scaled_proportions-reset_proportion-title'
)}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-scaled_proportions-reset_proportion-description'
)}
</Typography>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
onClick={prevStep}
>
{l10n.getString('onboarding-automatic_proportions-prev_step')}
</Button>
<Button
variant="secondary"
onClick={() => {
sendRPCPacket(
RpcMessage.SkeletonResetAllRequest,
new SkeletonResetAllRequestT()
);
nextStep();
}}
>
{l10n.getString('reset-reset_all')}
</Button>
</div>
</div>
</div>
</>
);
}

58
gui/src/hooks/height.ts Normal file
View File

@@ -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<React.SetStateAction<number | null>>;
floorHeight: number | null;
setFloorHeight: React.Dispatch<React.SetStateAction<number | null>>;
validateHeight: (
hmdHeight: number | null | undefined,
floorHeight: number | null | undefined
) => boolean;
}
export function useProvideHeightContext(): HeightContext {
const [hmdHeight, setHmdHeight] = useState<number | null>(null);
const [floorHeight, setFloorHeight] = useState<number | null>(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<HeightContext>(undefined as never);
export function useHeightContext() {
const context = useContext(HeightContextC);
if (!context) {
throw new Error('useHeightContext must be within a HeightContext Provider');
}
return context;
}

View File

@@ -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, Float>(
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<Epoch>? = 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,

View File

@@ -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<AutoBone.Epoch>?,
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)

View File

@@ -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<ProportionLimiter>(
// 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,
),

View File

@@ -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

View File

@@ -309,6 +309,15 @@ public class CurrentVRConfigConverter implements VersionedModelConverter {
// 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 (

View File

@@ -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<String, Float> offsets = new HashMap<>();
private float hmdHeight = 0f;
private float floorHeight = 0f;
public Map<String, Boolean> getToggles() {
return toggles;
}
@@ -35,4 +39,25 @@ public class SkeletonConfig {
public Map<String, Float> getValues() {
return values;
}
public float getHmdHeight() {
return hmdHeight;
}
public void setHmdHeight(float hmdHeight) {
this.hmdHeight = hmdHeight;
}
public float getFloorHeight() {
return floorHeight;
}
public void setFloorHeight(float hmdHeight) {
this.floorHeight = hmdHeight;
}
@JsonIgnore
public float getUserHeight() {
return hmdHeight - floorHeight;
}
}

View File

@@ -1,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<RpcMessageHeade
fun onHeightRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) {
val fbb = FlatBufferBuilder(32)
val hmdHeight = api.server.humanPoseManager.hmdHeight
val response = HeightResponse
.createHeightResponse(
fbb,
hmdHeight,
hmdHeight / BodyProportionError.eyeHeightToHeightRatio,
)
val posTrackers = api.server.allTrackers.filter { !it.isInternal && it.status == TrackerStatus.OK && it.hasPosition && it.trackerPosition != null }
val response = if (posTrackers.isNotEmpty()) {
HeightResponse
.createHeightResponse(
fbb,
posTrackers.minOf { it.position.y },
posTrackers.maxOf { it.position.y },
)
} else {
HeightResponse
.createHeightResponse(
fbb,
0f,
0f,
)
}
fbb.finish(createRPCMessage(fbb, RpcMessage.HeightResponse, response))
conn.send(fbb.dataBuffer())
}

View File

@@ -10,10 +10,7 @@ import dev.slimevr.tracking.processor.config.SkeletonConfigToggles;
import dev.slimevr.tracking.processor.config.SkeletonConfigValues;
import dev.slimevr.tracking.trackers.TrackerRole;
import solarxr_protocol.rpc.*;
import solarxr_protocol.rpc.settings.LegTweaksSettings;
import solarxr_protocol.rpc.settings.ModelRatios;
import solarxr_protocol.rpc.settings.ModelSettings;
import solarxr_protocol.rpc.settings.ModelToggles;
import solarxr_protocol.rpc.settings.*;
public class RPCSettingsBuilder {
@@ -176,7 +173,8 @@ public class RPCSettingsBuilder {
public static int createModelSettings(
FlatBufferBuilder fbb,
HumanPoseManager humanPoseManager,
LegTweaksConfig legTweaksConfig
LegTweaksConfig legTweaksConfig,
SkeletonConfig skeletonConfig
) {
int togglesOffset = ModelToggles
.createModelToggles(
@@ -211,7 +209,20 @@ public class RPCSettingsBuilder {
fbb,
legTweaksConfig.getCorrectionStrength()
);
return ModelSettings.createModelSettings(fbb, togglesOffset, ratiosOffset, legTweaksOffset);
int skeletonConfigOffset = SkeletonHeight
.createSkeletonHeight(
fbb,
skeletonConfig.getHmdHeight(),
skeletonConfig.getFloorHeight()
);
return ModelSettings
.createModelSettings(
fbb,
togglesOffset,
ratiosOffset,
legTweaksOffset,
skeletonConfigOffset
);
}
public static int createAutoBoneSettings(FlatBufferBuilder fbb, AutoBoneConfig autoBoneConfig) {
@@ -233,8 +244,6 @@ public class RPCSettingsBuilder {
autoBoneConfig.getPositionErrorFactor(),
autoBoneConfig.getPositionOffsetErrorFactor(),
autoBoneConfig.getCalcInitError(),
autoBoneConfig.getTargetHmdHeight(),
autoBoneConfig.getTargetFullHeight(),
autoBoneConfig.getRandomizeFrameOrder(),
autoBoneConfig.getScaleEachStep(),
autoBoneConfig.getSampleCount(),
@@ -305,12 +314,6 @@ public class RPCSettingsBuilder {
if (autoBoneSettings.hasCalcInitError()) {
autoBoneConfig.setCalcInitError(autoBoneSettings.calcInitError());
}
if (autoBoneSettings.hasTargetHmdHeight()) {
autoBoneConfig.setTargetHmdHeight(autoBoneSettings.targetHmdHeight());
}
if (autoBoneSettings.hasTargetFullHeight()) {
autoBoneConfig.setTargetFullHeight(autoBoneSettings.targetFullHeight());
}
if (autoBoneSettings.hasRandomizeFrameOrder()) {
autoBoneConfig.setRandomizeFrameOrder(autoBoneSettings.randomizeFrameOrder());
}
@@ -387,7 +390,8 @@ public class RPCSettingsBuilder {
.createModelSettings(
fbb,
server.humanPoseManager,
server.configManager.getVrConfig().getLegTweaks()
server.configManager.getVrConfig().getLegTweaks(),
server.configManager.getVrConfig().getSkeleton()
),
RPCSettingsBuilder
.createTapDetectionSettings(

View File

@@ -319,6 +319,11 @@ class RPCSettingsHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) {
api.server.humanPoseManager.updateLegTweaksConfig()
}
modelSettings.skeletonHeight()?.let {
api.server.configManager.vrConfig.skeleton.hmdHeight = it.hmdHeight()
api.server.configManager.vrConfig.skeleton.floorHeight = it.floorHeight()
}
hpm.saveConfig()
}

View File

@@ -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
@@ -441,18 +441,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(