diff --git a/gui/package.json b/gui/package.json index 761c0a15f..00deaa49e 100644 --- a/gui/package.json +++ b/gui/package.json @@ -19,8 +19,8 @@ "@tauri-apps/api": "^2.0.2", "@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-fs": "2.4.1", - "@tauri-apps/plugin-opener": "^2.4.0", "@tauri-apps/plugin-http": "^2.5.0", + "@tauri-apps/plugin-opener": "^2.4.0", "@tauri-apps/plugin-log": "~2", "@tauri-apps/plugin-os": "^2.0.0", "@tauri-apps/plugin-shell": "^2.3.0", @@ -30,6 +30,7 @@ "ajv": "^8.17.1", "browser-fs-access": "^0.35.0", "classnames": "^2.5.1", + "convert": "^5.12.0", "flatbuffers": "22.10.26", "intl-pluralrules": "^2.0.1", "ip-num": "^1.5.1", diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 3090887df..39863a07b 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -27,6 +27,13 @@ tips-tap_setup = You can slowly tap your tracker 2 times to choose it instead of tips-turn_on_tracker = Using official SlimeVR trackers? Don't forget to turn on your tracker after connecting it to the PC! tips-failed_webgl = Failed to initialize WebGL. +## Units +unit-meter = Meter +unit-foot = Foot +unit-inch = Inch +unit-cm = cm + + ## Body parts body_part-NONE = Unassigned body_part-HEAD = Head @@ -1166,7 +1173,7 @@ onboarding-automatic_mounting-put_trackers_on-next = I have all my trackers on onboarding-automatic_mounting-return-home = Done ## Tracker manual proportions setupa -onboarding-manual_proportions-back = Go Back to Reset tutorial +onboarding-manual_proportions-back-scaled = Go back to Scaled Proportions onboarding-manual_proportions-title = Manual Body Proportions onboarding-manual_proportions-fine_tuning_button = Automatically fine tune proportions onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Please connect a VR headset to use automatic fine tuning @@ -1270,28 +1277,33 @@ onboarding-automatic_proportions-smol_warning = 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-v2 = 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-v2 = Your full height is -onboarding-scaled_proportions-manual_height-estimated_height = Your estimated headset height is: -onboarding-scaled_proportions-manual_height-next_step = Continue and save -onboarding-scaled_proportions-manual_height-warning = - You are currently using the manual way of setting up scaled proportions! - This mode is recommended only if you do not use an HMD with SlimeVR. - To be able to use the automatic scaled proportions please: -onboarding-scaled_proportions-manual_height-warning-no_hmd = Connect a VR headset -onboarding-scaled_proportions-manual_height-warning-no_controllers = Make sure your controllers are connected and correctly assigned to your hands +## User height calibration +onboarding-user_height-title = What is your height? +onboarding-user_height-description = We need your height to calculate your body proportions and accurately represent your movements. You can either let SlimeVR calculate it, or input your height manually. +onboarding-user_height-need_head_tracker = An HMD (or Head tracker) and controllers with positional tracking are required to perform the calibration. +onboarding-user_height-calculate = Calculate my height automatically +onboarding-user_height-next_step = Continue and save +onboarding-user_height-manual-proportions = Manual Proportions +onboarding-user_height-calibration-title = Calibration Progress +onboarding-user_height-calibration-RECORDING_FLOOR = Touch the floor with the tip of your controller +onboarding-user_height-calibration-WAITING_FOR_RISE = Stand back up +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Stand back up and look forward +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Make sure your head is leveled +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Do not look at the floor +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Do not look too high up +onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Make sure the controller is pointing down +onboarding-user_height-calibration-RECORDING_HEIGHT = Stand back up and stand still! +onboarding-user_height-calibration-DONE = Success! +onboarding-user_height-calibration-ERROR_TIMEOUT = Calibration timed out, try again. +onboarding-user_height-calibration-ERROR_TOO_HIGH = The detected user height is too high, Try again. +onboarding-user_height-calibration-ERROR_TOO_SMALL = The detected user height is too small. Make sure to sand fully up during the process. +onboarding-user_height-calibration-error = Calibration Failed +onboarding-user_height-manual-tip = While adjusting your height, try different poses and see how the skeleton matches your body. +onboarding-user_height-reset-warning = You already had proportions set using the manual proportions page. + Continuing will reset these proportions to use only your height. -## 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. + Do you want to continue? ## Stay Aligned setup onboarding-stay_aligned-title = Stay Aligned diff --git a/gui/public/images/user-height/controller-ok.webp b/gui/public/images/user-height/controller-ok.webp new file mode 100644 index 000000000..d04d7e685 Binary files /dev/null and b/gui/public/images/user-height/controller-ok.webp differ diff --git a/gui/public/images/user-height/controller-wrong-1.webp b/gui/public/images/user-height/controller-wrong-1.webp new file mode 100644 index 000000000..3cf2c2076 Binary files /dev/null and b/gui/public/images/user-height/controller-wrong-1.webp differ diff --git a/gui/public/images/user-height/controller-wrong-2.webp b/gui/public/images/user-height/controller-wrong-2.webp new file mode 100644 index 000000000..b1c778da5 Binary files /dev/null and b/gui/public/images/user-height/controller-wrong-2.webp differ diff --git a/gui/public/images/user-height/done.webp b/gui/public/images/user-height/done.webp new file mode 100644 index 000000000..27b9a965f Binary files /dev/null and b/gui/public/images/user-height/done.webp differ diff --git a/gui/public/images/user-height/look-forward-high.webp b/gui/public/images/user-height/look-forward-high.webp new file mode 100644 index 000000000..9e6a653ff Binary files /dev/null and b/gui/public/images/user-height/look-forward-high.webp differ diff --git a/gui/public/images/user-height/look-forward-low.webp b/gui/public/images/user-height/look-forward-low.webp new file mode 100644 index 000000000..41b4d3d83 Binary files /dev/null and b/gui/public/images/user-height/look-forward-low.webp differ diff --git a/gui/public/images/user-height/look-forward-ok.webp b/gui/public/images/user-height/look-forward-ok.webp new file mode 100644 index 000000000..7baf329f0 Binary files /dev/null and b/gui/public/images/user-height/look-forward-ok.webp differ diff --git a/gui/public/images/user-height/stand-still.webp b/gui/public/images/user-height/stand-still.webp new file mode 100644 index 000000000..3e7dc198e Binary files /dev/null and b/gui/public/images/user-height/stand-still.webp differ diff --git a/gui/public/images/user-height/timeout.webp b/gui/public/images/user-height/timeout.webp new file mode 100644 index 000000000..3bd3f07df Binary files /dev/null and b/gui/public/images/user-height/timeout.webp differ diff --git a/gui/public/images/user-height/touch-floor.webp b/gui/public/images/user-height/touch-floor.webp new file mode 100644 index 000000000..61c8ec47f Binary files /dev/null and b/gui/public/images/user-height/touch-floor.webp differ diff --git a/gui/public/images/user-height/wrong-height.webp b/gui/public/images/user-height/wrong-height.webp new file mode 100644 index 000000000..540e9d1ca Binary files /dev/null and b/gui/public/images/user-height/wrong-height.webp differ diff --git a/gui/src/components/commons/ArrowLink.tsx b/gui/src/components/commons/ArrowLink.tsx index 40de60460..c68ef3573 100644 --- a/gui/src/components/commons/ArrowLink.tsx +++ b/gui/src/components/commons/ArrowLink.tsx @@ -12,7 +12,7 @@ export function ArrowLink({ }: { to: string; children: ReactNode; - state?: { SerialPort?: string }; + state?: any; direction?: 'left' | 'right'; variant?: 'flat' | 'boxed' | 'boxed-2'; }) { diff --git a/gui/src/components/commons/Tooltip.tsx b/gui/src/components/commons/Tooltip.tsx index fbc29203f..898fd0452 100644 --- a/gui/src/components/commons/Tooltip.tsx +++ b/gui/src/components/commons/Tooltip.tsx @@ -1,4 +1,3 @@ -import { useBreakpoint } from '@/hooks/breakpoint'; import classNames from 'classnames'; import { ReactNode, @@ -462,11 +461,11 @@ export function Tooltip({ spacing = 10, }: TooltipProps) { const childRef = useRef(null); - const { isMobile } = useBreakpoint('mobile'); + const isAndroid = window.__ANDROID__?.isThere(); let portal = null; if (variant === 'auto') { - portal = isMobile ? ( + portal = isAndroid ? ( {content} ) : ( +
void; +}) { + const { isXs } = useBreakpoint('xs'); + + return ( +
+ {open ? ( + + ) : ( + + )} +
+ ); +} + function ProportionItem({ type, part, @@ -119,38 +149,46 @@ function ProportionItem({ key={part.label} itemID={part.label} className={classNames( - 'flex justify-center gap-6 mobile:gap-2 p-2 mobile:px-2 px-4', - part.type === 'group-part' - ? 'bg-background-50 group/child-buttons' - : 'bg-background-70 group/buttons' + 'flex justify-center gap-6 mobile:gap-2 p-2 mobile:pt-0 mobile:px-2 px-4', + part.type === 'group-part' ? 'bg-background-50' : 'bg-background-70' )} >
-
+
{l10n.getString(part.label)} - - + } preferedDirection="bottom" - mode="corner" >
i
+
+ {type === 'ratio' && part.type !== 'group-part' && ( + {}} + part={part} + /> + )} +
@@ -219,18 +257,11 @@ function ProportionItem({
- {type === 'ratio' && part.type !== 'group-part' && ( -
- {open ? : } -
- )} +
+ {type === 'ratio' && part.type !== 'group-part' && ( + + )} +
{part.type === 'group' && (
void; +}) { + const { isXs } = useBreakpoint('xs'); + const { currentLocales } = useLocaleConfig(); + + const format = useMemo(() => { + if (unit == 'cm') return `${value > 0 ? '+' : ''}${value}`; + const feetFormatter = new Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: unit, + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }); + return `${value > 0 ? '+' : ''}${feetFormatter.format(value)}`; + }, [currentLocales, value]); + + return ( +
!disabled && onClick()} + > + + {format} + + {unit == 'cm' && isXs && ( + + )} +
+ ); +} + +function UnitSelector({ + name, + active, + onClick, +}: { + name: string; + active: boolean; + onClick: () => void; +}) { + return ( +
+ +
+ ); +} + +function formatInFoot(meters: number, locale: string[]) { + const totalInches = Math.round(convert(meters, 'meter').to('inch')); + const feet = Math.floor(totalInches / 12); + const inches = totalInches % 12; + + const feetFormatter = new Intl.NumberFormat(locale, { + style: 'unit', + unit: 'foot', + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }); + + const inchFormatter = new Intl.NumberFormat(locale, { + style: 'unit', + unit: 'inch', + unitDisplay: 'narrow', + maximumFractionDigits: 0, + }); + + return `${feetFormatter.format(feet)} ${inchFormatter.format(inches)}`; +} + +const round4Digit = (value: number) => Math.round(value * 10000) / 10000; + +export function HeightSelectionInput({ + disabled = false, + hmdHeight, + setHmdHeight, +}: { + disabled?: boolean; + hmdHeight: number; + setHmdHeight: (height: number) => void; +}) { + if (!hmdHeight) disabled = true; + + const { isXs } = useBreakpoint('xs'); + const [unit, setUnit] = useState<'meter' | 'foot'>('meter'); + const { currentLocales } = useLocaleConfig(); + + const formattedHeight = useMemo(() => { + if (!hmdHeight) return '--'; + + const fullHeight = hmdHeight / EYE_HEIGHT_TO_HEIGHT_RATIO; + const displayHeight = round4Digit(fullHeight); + + if (unit === 'meter') { + return new Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: 'meter', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }).format(displayHeight); + } + + return formatInFoot(displayHeight, currentLocales); + }, [hmdHeight, unit]); + + const incrementMath = (unit: 'inch' | 'cm' | 'foot', value: number) => { + const incrementInMeters = convert(value, unit).to('meter'); + const oldFull = hmdHeight / EYE_HEIGHT_TO_HEIGHT_RATIO; + const newFull = oldFull + incrementInMeters; + const newEye = newFull * EYE_HEIGHT_TO_HEIGHT_RATIO; + + return round4Digit(newEye); + }; + + const increment = (unit: 'inch' | 'cm' | 'foot', value: number) => { + const newEye = incrementMath(unit, value); + setHmdHeight(newEye); + }; + + const canIcrement = ( + unit: 'inch' | 'cm' | 'foot', + value: number, + max: number + ) => { + const newEye = incrementMath(unit, value); + return value < 0 ? newEye >= max : newEye < max; + }; + + const handleUnitChange = (newUnit: 'meter' | 'foot') => { + if (!hmdHeight || newUnit === unit) return; + + const fullHeight = hmdHeight / EYE_HEIGHT_TO_HEIGHT_RATIO; + let snappedHeight; + + if (newUnit === 'foot') { + // Snap to nearest inch + const totalInches = Math.round(convert(fullHeight, 'meter').to('inch')); + snappedHeight = convert(totalInches, 'inch').to('meter'); + } else { + // Snap to nearest centimeter + const totalCm = Math.round(convert(fullHeight, 'meter').to('cm')); + snappedHeight = convert(totalCm, 'cm').to('meter'); + } + + const newEyeHeight = round4Digit( + snappedHeight * EYE_HEIGHT_TO_HEIGHT_RATIO + ); + setHmdHeight(newEyeHeight); + setUnit(newUnit); + }; + + return ( +
+
+ {unit === 'foot' && ( + <> + increment('foot', -1)} + disabled={disabled || !canIcrement('foot', -1, 0.9)} + /> + increment('inch', -1)} + disabled={disabled || !canIcrement('inch', -1, 0.9)} + /> + + )} + {unit === 'meter' && ( + <> + increment('cm', -10)} + disabled={disabled || !canIcrement('cm', -10, 0.9)} + /> + increment('cm', -1)} + disabled={disabled || !canIcrement('cm', -1, 0.9)} + /> + + )} +
+
+
+ {formattedHeight} +
+
+ handleUnitChange('meter')} + /> + handleUnitChange('foot')} + /> +
+
+
+ {unit === 'foot' && ( + <> + increment('inch', 1)} + disabled={disabled || !canIcrement('inch', 1, 2.4)} + /> + increment('foot', 1)} + disabled={disabled || !canIcrement('foot', 1, 2.4)} + /> + + )} + {unit === 'meter' && ( + <> + increment('cm', 1)} + disabled={disabled || !canIcrement('cm', 1, 2.4)} + /> + increment('cm', 10)} + disabled={disabled || !canIcrement('cm', 10, 2.4)} + /> + + )} +
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx index 047a5c785..812489dac 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx @@ -36,6 +36,7 @@ import { useLocaleConfig } from '@/i18n/config'; import { useNavigate } from 'react-router-dom'; import { ResetButton } from '@/components/home/ResetButton'; import { Vector3 } from 'three'; +import { ArrowLink } from '@/components/commons/ArrowLink'; function IconButton({ onClick, @@ -445,6 +446,17 @@ export function ManualProportionsPage() { <>
+
+ + + LINK + + +
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. -
- Are you sure you want to do this? -
+ WARNING
diff --git a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx index 6ea8a4fb7..d4b5105a4 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx @@ -1,121 +1,493 @@ -import { Localized, 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 { ManualHeightStep } from './scaled-steps/ManualHeightStep'; import { Button } from '@/components/commons/Button'; -import { WarningBox } from '@/components/commons/TipBox'; -import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; -import { flatTrackersAtom } from '@/store/app-store'; -import { BodyPart } from 'solarxr-protocol'; +import { serverGuardsAtom } from '@/store/app-store'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { useEffect, useState } from 'react'; +import { + CancelUserHeightCalibrationT, + ChangeSettingsRequestT, + ModelSettingsT, + ResetType, + RpcMessage, + SkeletonConfigRequestT, + SkeletonConfigResponseT, + SkeletonHeightT, + SkeletonResetAllRequestT, + StartUserHeightCalibrationT, + UserHeightCalibrationStatus, + UserHeightRecordingStatusResponseT, +} from 'solarxr-protocol'; +import { HeightSelectionInput } from './HeightInput'; +import { Tooltip } from '@/components/commons/Tooltip'; +import classNames from 'classnames'; +import { SkeletonVisualizerWidget } from '@/components/widgets/SkeletonVisualizerWidget'; +import { Vector3 } from 'three'; +import { CheckIcon } from '@/components/commons/icon/CheckIcon'; +import { useDebouncedEffect } from '@/hooks/timeout'; +import { restartAndPlay, scaledProportionsClick } from '@/sounds/sounds'; +import { CrossIcon } from '@/components/commons/icon/CrossIcon'; +import { TipBox } from '@/components/commons/TipBox'; +import { Localized } from '@fluent/react'; +import { ResetButton } from '@/components/home/ResetButton'; +import { ProgressBar } from '@/components/commons/ProgressBar'; +import { useBreakpoint } from '@/hooks/breakpoint'; +import { useConfig } from '@/hooks/config'; +import { ProportionsResetModal } from './ProportionsResetModal'; + +const statusSteps = [ + // Order matters be carefull + UserHeightCalibrationStatus.NONE, + UserHeightCalibrationStatus.RECORDING_FLOOR, + UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH, + UserHeightCalibrationStatus.WAITING_FOR_RISE, + UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK, + UserHeightCalibrationStatus.RECORDING_HEIGHT, + UserHeightCalibrationStatus.DONE, +]; + +const progressSteps: UserHeightCalibrationStatus[] = statusSteps.filter( + (s) => s !== UserHeightCalibrationStatus.NONE +); + +const errorSteps = [ + UserHeightCalibrationStatus.ERROR_TIMEOUT, + UserHeightCalibrationStatus.ERROR_TOO_HIGH, + UserHeightCalibrationStatus.ERROR_TOO_SMALL, +]; + +const statusToImage: Record = { + [UserHeightCalibrationStatus.NONE]: null, + [UserHeightCalibrationStatus.DONE]: '/images/user-height/done.webp', + [UserHeightCalibrationStatus.RECORDING_FLOOR]: + '/images/user-height/touch-floor.webp', + [UserHeightCalibrationStatus.WAITING_FOR_RISE]: + '/images/user-height/stand-still.webp', + [UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH]: null, + [UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK]: null, + [UserHeightCalibrationStatus.RECORDING_HEIGHT]: + '/images/user-height/stand-still.webp', + [UserHeightCalibrationStatus.ERROR_TIMEOUT]: + '/images/user-height/timeout.webp', + [UserHeightCalibrationStatus.ERROR_TOO_HIGH]: + '/images/user-height/wrong-height.webp', + [UserHeightCalibrationStatus.ERROR_TOO_SMALL]: + '/images/user-height/wrong-height.webp', +}; + +function Stepper({ status }: { status: UserHeightRecordingStatusResponseT }) { + const stepIndex = progressSteps.indexOf(status.status); + const isError = errorSteps.includes(status.status); + const progress = isError ? 1 : (stepIndex + 1) / progressSteps.length; + + const { isXs } = useBreakpoint('xs'); + + return ( +
+
+
+ {status.status !== UserHeightCalibrationStatus.DONE && !isError && ( + + {stepIndex + 1} + + )} + {status.status === UserHeightCalibrationStatus.DONE && ( + + )} + {isError && } +
+ +
+ +
+ ); +} + +function UserHeightStatus({ + status, +}: { + status: UserHeightRecordingStatusResponseT; +}) { + const { isXs } = useBreakpoint('xs'); + + return ( +
+
+
+ +
+
+ +
+
+ +
+ {statusToImage[status.status] && ( +
+ +
+ )} + {status.status === UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK && ( +
+
+
+ + + +
+
+ + + + +
+
+ + + +
+
+
+ )} + {status.status === + UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH && ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ )} +
+
+ ); +} export function ScaledProportionsPage() { - const { l10n } = useLocalization(); + const [hmdHeight, setHmdHeight] = useState(0); + const [tmpHeight, setTmpHeight] = useState(0); + const { config, setConfig } = useConfig(); const { applyProgress, state } = useOnboarding(); - const heightContext = useProvideHeightContext(); - const navigate = useNavigate(); - const trackers = useAtomValue(flatTrackersAtom); - const { hasHmd, hasHandControllers } = useMemo(() => { - const hasHmd = trackers.some( - (tracker) => - tracker.tracker.info?.bodyPart === BodyPart.HEAD && - (tracker.tracker.info.isHmd || tracker.tracker.position?.y) - ); - const hasHandControllers = - trackers.filter( - (tracker) => - tracker.tracker.info?.bodyPart === BodyPart.LEFT_HAND || - tracker.tracker.info?.bodyPart === BodyPart.RIGHT_HAND - ).length >= 2; + const serverGuards = useAtomValue(serverGuardsAtom); - return { hasHmd, hasHandControllers }; - }, [trackers]); - - const canDoAuto = hasHmd && hasHandControllers; + const [status, setState] = useState(); + const [auto, setAuto] = useState(false); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [resetModal, setResetModal] = useState(null); applyProgress(0.9); - return ( - -
-
-
- - {l10n.getString('onboarding-scaled_proportions-title')} - -
- - {l10n.getString('onboarding-scaled_proportions-description')} - -
-
+ const start = () => { + sendRPCPacket( + RpcMessage.StartUserHeightCalibration, + new StartUserHeightCalibrationT() + ); + }; - {!canDoAuto && ( - - }} - > - - -
    - {!hasHmd && ( - -
  • - - )} - {!hasHandControllers && ( - -
  • - - )} -
-
- )} -
- navigate('/onboarding/reset-tutorial', { state })} - /> + const cancel = () => { + sendRPCPacket( + RpcMessage.CancelUserHeightCalibration, + new CancelUserHeightCalibrationT() + ); + }; + + // Makes it so you dont get spammed by sounds if multiple status complete at once + useDebouncedEffect( + () => { + if (!config || !config.feedbackSound) return; + if ( + !status || + errorSteps.includes(status.status) || + status.status == UserHeightCalibrationStatus.NONE || + status.status == UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK || + status.status == + UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH + ) + return; + restartAndPlay(scaledProportionsClick, config.feedbackSoundVolume); + }, + [status?.status], + 300 + ); + + const applyHeight = (newHeight: number) => { + setHmdHeight(newHeight); + const settingsRequest = new ChangeSettingsRequestT(); + settingsRequest.modelSettings = new ModelSettingsT( + null, + null, + null, + new SkeletonHeightT(newHeight, 0) + ); + sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest); + sendRPCPacket( + RpcMessage.SkeletonResetAllRequest, + new SkeletonResetAllRequestT() + ); + setConfig({ lastUsedProportions: 'scaled' }); + }; + + useRPCPacket( + RpcMessage.UserHeightRecordingStatusResponse, + (res: UserHeightRecordingStatusResponseT) => { + if (res.status !== UserHeightCalibrationStatus.NONE) { + setAuto(true); + } + + setState(res); + setHmdHeight(res.hmdHeight); + + if (res.status === UserHeightCalibrationStatus.DONE) { + setConfig({ lastUsedProportions: 'scaled' }); + } + } + ); + + useRPCPacket( + RpcMessage.SkeletonConfigResponse, + (res: SkeletonConfigResponseT) => { + setHmdHeight(res.userHeight); + } + ); + + useEffect(() => { + sendRPCPacket( + RpcMessage.SkeletonConfigRequest, + new SkeletonConfigRequestT() + ); + + return () => { + cancel(); + }; + }, []); + + useEffect(() => { + const checkNotAuto = (status: UserHeightCalibrationStatus) => + status === UserHeightCalibrationStatus.DONE || + errorSteps.includes(status); + + if (status && checkNotAuto(status.status)) { + const id = setTimeout( + () => { + if (status && checkNotAuto(status.status)) { + setAuto(false); + sendRPCPacket( + RpcMessage.SkeletonConfigRequest, + new SkeletonConfigRequestT() + ); // Re ask the user height so it resets back to the correct value + } + }, + status.status === UserHeightCalibrationStatus.DONE ? 2000 : 10_000 + ); + return () => { + clearTimeout(id); + }; + } + }, [status]); + + const acceptHeight = () => { + if (resetModal === 'manual') { + applyHeight(tmpHeight); + } else if (resetModal === 'auto') { + start(); + } + + setResetModal(null); + }; + + return ( +
+ setResetModal(null)} + accept={acceptHeight} + /> +
+ {!auto && ( +
+ + PRO TIP +
- {state.alonePage && ( -
- + )} +
+ {status && } +
+ +
+ + { + if ( + config?.lastUsedProportions != null && + config.lastUsedProportions !== 'scaled' + ) { + setTmpHeight(height); + setResetModal('manual'); + } else setHmdHeight(height); + setAuto(false); + }} + /> + + } + > +
- )} + + + )} + {!state.alonePage && ( +
- +
+ { + context.addView({ + left: 0, + bottom: 0, + width: 1, + height: 1, + position: new Vector3(3, 2.5, -3), + onHeightChange(v, newHeight) { + v.controls.target.set(0, newHeight / 2.9, 0); + const scale = Math.max(1, newHeight) / 1; + v.camera.zoom = 1 / scale; + }, + }); + }} + /> +
+
); } 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 deleted file mode 100644 index 5a794a8bd..000000000 --- a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckFloorHeight.tsx +++ /dev/null @@ -1,221 +0,0 @@ -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 { - EYE_HEIGHT_TO_HEIGHT_RATIO, - useHeightContext, - validateHeight, -} 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 } = 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)} - - - {l10n.getString( - 'onboarding-automatic_proportions-check_floor_height-full_height' - )} - - - {mFormat.format( - ((hmdHeight ?? 0) - (floorHeight ?? 0)) / - EYE_HEIGHT_TO_HEIGHT_RATIO - )} - -
-
-
- {/* 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 deleted file mode 100644 index 967be0dac..000000000 --- a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/CheckHeight.tsx +++ /dev/null @@ -1,166 +0,0 @@ -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 { useMemo, useState } from 'react'; -import { useLocaleConfig } from '@/i18n/config'; -import { TipBox } from '@/components/commons/TipBox'; -import { useHeightContext } from '@/hooks/height'; -import { useInterval } from '@/hooks/timeout'; -import { useOnboarding } from '@/hooks/onboarding'; - -export function CheckHeightStep({ - nextStep, - prevStep, - variant, -}: { - nextStep: () => void; - prevStep: () => void; - variant: 'onboarding' | 'alone'; -}) { - const { state } = useOnboarding(); - const { l10n } = useLocalization(); - const { hmdHeight, setHmdHeight } = useHeightContext(); - const [fetchHeight, setFetchHeight] = useState(false); - const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); - const { currentLocales } = useLocaleConfig(); - - 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, ({ maxHeight }: HeightResponseT) => { - if (fetchHeight) { - setHmdHeight((val) => - val === null ? maxHeight : Math.max(maxHeight, val) - ); - } - }); - return ( - <> -
-
-
- - {l10n.getString( - 'onboarding-automatic_proportions-check_height-title-v3' - )} - -
- - {l10n.getString( - 'onboarding-automatic_proportions-check_height-description-v2' - )} - - }} - > - - 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)} - -
-
-
-
- Reset position -
-
- -
- {!state.alonePage && ( - - )} - -
-
- - ); -} 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 deleted file mode 100644 index d6add4bb0..000000000 --- a/gui/src/components/onboarding/pages/body-proportions/scaled-steps/Done.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 6d3182bca..000000000 --- a/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ManualHeightStep.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { Button } from '@/components/commons/Button'; -import { Typography } from '@/components/commons/Typography'; -import { useLocalization } from '@fluent/react'; -import { useEffect, useMemo } from 'react'; -import { useLocaleConfig } from '@/i18n/config'; -import { - DEFAULT_FULL_HEIGHT, - EYE_HEIGHT_TO_HEIGHT_RATIO, - useHeightContext, -} from '@/hooks/height'; -import { useForm } from 'react-hook-form'; -import { - ChangeSettingsRequestT, - ModelSettingsT, - RpcMessage, - SkeletonHeightT, -} from 'solarxr-protocol'; -import { NumberSelector } from '@/components/commons/NumberSelector'; -import { useOnboarding } from '@/hooks/onboarding'; -import { MIN_HEIGHT } from '@/hooks/manual-proportions'; - -interface HeightForm { - height: number; -} - -export function ManualHeightStep({ - nextStep, - prevStep, - variant, -}: { - nextStep: () => void; - prevStep: () => void; - variant: 'onboarding' | 'alone'; -}) { - const { state } = useOnboarding(); - const { l10n } = useLocalization(); - const { setHmdHeight, currentHeight } = useHeightContext(); - const { control, handleSubmit, formState, watch, reset } = - useForm({ - defaultValues: { height: DEFAULT_FULL_HEIGHT }, - }); - const { sendRPCPacket } = useWebsocketAPI(); - const { currentLocales } = useLocaleConfig(); - const height = watch('height'); - - // Load the last configured height - useEffect(() => { - reset({ - height: - (currentHeight && currentHeight / EYE_HEIGHT_TO_HEIGHT_RATIO) || - DEFAULT_FULL_HEIGHT, - }); - }, [currentHeight]); - - const mFormat = useMemo( - () => - new Intl.NumberFormat(currentLocales, { - style: 'unit', - unit: 'meter', - maximumFractionDigits: 2, - }), - [currentLocales] - ); - - const submitFullHeight = (values: HeightForm) => { - const newHeight = values.height * EYE_HEIGHT_TO_HEIGHT_RATIO; - setHmdHeight(newHeight); - const settingsRequest = new ChangeSettingsRequestT(); - settingsRequest.modelSettings = new ModelSettingsT( - null, - null, - null, - new SkeletonHeightT(newHeight, 0) - ); - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest); - nextStep(); - }; - - return ( -
-
-
- - {l10n.getString( - 'onboarding-scaled_proportions-manual_height-title' - )} - -
- - {l10n.getString( - 'onboarding-scaled_proportions-manual_height-description-v2' - )} - -
-
- - 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} - /> -
-
- - {l10n.getString( - 'onboarding-scaled_proportions-manual_height-estimated_height' - )} - - - {mFormat.format(height * EYE_HEIGHT_TO_HEIGHT_RATIO)} - -
-
-
- -
- {!state.alonePage && ( - - )} - -
-
- ); -} 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 deleted file mode 100644 index cbc5546ad..000000000 --- a/gui/src/components/onboarding/pages/body-proportions/scaled-steps/ResetProportions.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Button } from '@/components/commons/Button'; -import { Typography } from '@/components/commons/Typography'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { useLocalization } from '@fluent/react'; -import { RpcMessage, SkeletonResetAllRequestT } from 'solarxr-protocol'; - -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/widgets/SkeletonVisualizerWidget.tsx b/gui/src/components/widgets/SkeletonVisualizerWidget.tsx index 2aa41503f..f60b2e30e 100644 --- a/gui/src/components/widgets/SkeletonVisualizerWidget.tsx +++ b/gui/src/components/widgets/SkeletonVisualizerWidget.tsx @@ -426,7 +426,13 @@ export function SkeletonVisualizerWidget({ )} >
toggleDisabled?.()} > diff --git a/gui/src/hooks/autobone.ts b/gui/src/hooks/autobone.ts index b91cb2b0b..c4b0b9d24 100644 --- a/gui/src/hooks/autobone.ts +++ b/gui/src/hooks/autobone.ts @@ -12,6 +12,7 @@ import { import { useWebsocketAPI } from './websocket-api'; import { useLocalization } from '@fluent/react'; import { log } from '@/utils/logging'; +import { useConfig } from './config'; export enum ProcessStatus { PENDING, @@ -31,6 +32,7 @@ export interface AutoboneContext { } export function useProvideAutobone(): AutoboneContext { + const { setConfig } = useConfig(); const { l10n } = useLocalization(); const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); const [hasRecording, setHasRecording] = useState(ProcessStatus.PENDING); @@ -78,6 +80,7 @@ export function useProvideAutobone(): AutoboneContext { const applyProcessing = () => { sendRPCPacket(RpcMessage.AutoBoneApplyRequest, new AutoBoneApplyRequestT()); + setConfig({ lastUsedProportions: 'autobone' }); }; useRPCPacket( diff --git a/gui/src/hooks/config.ts b/gui/src/hooks/config.ts index 934afd7b0..06dac4276 100644 --- a/gui/src/hooks/config.ts +++ b/gui/src/hooks/config.ts @@ -48,6 +48,7 @@ export interface Config { bvhDirectory: string | null; homeLayout: 'default' | 'table'; skeletonPreview: boolean; + lastUsedProportions: 'manual' | 'autobone' | 'scaled' | null; } export interface ConfigContext { @@ -79,6 +80,7 @@ export const defaultConfig: Config = { bvhDirectory: null, homeLayout: 'default', skeletonPreview: true, + lastUsedProportions: null, }; interface CrossStorage { diff --git a/gui/src/hooks/manual-proportions.ts b/gui/src/hooks/manual-proportions.ts index f5c76207b..0e7591f3a 100644 --- a/gui/src/hooks/manual-proportions.ts +++ b/gui/src/hooks/manual-proportions.ts @@ -7,6 +7,7 @@ import { } from 'solarxr-protocol'; import { useWebsocketAPI } from './websocket-api'; import { useEffect, useMemo, useState } from 'react'; +import { useConfig } from './config'; type LabelBase = { value: number; @@ -56,13 +57,15 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { changeBoneValue: (params: UpdateBoneParams) => void; } { const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); - const [config, setConfig] = useState | null>( - null - ); + const { setConfig } = useConfig(); + const [skeleton, setSkeleton] = useState | null>(null); const bodyPartsGrouped: Label[] = useMemo(() => { - if (!config) return []; + if (!skeleton) return []; if (type === 'linear') { - return config.skeletonParts.map( + return skeleton.skeletonParts.map( ({ bone, value }) => ({ type: 'bone', @@ -78,12 +81,12 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { ...[...BONE_MAPPING.keys()].map((groupName) => { const groupBones = BONE_MAPPING.get(groupName); if (!groupBones) throw 'invalid state - this value should always exits'; - const total = config.skeletonParts + const total = skeleton.skeletonParts .filter(({ bone }) => groupBones.includes(bone)) .reduce((acc, cur) => cur.value + acc, 0); return { type: 'group', - bones: config.skeletonParts + bones: skeleton.skeletonParts .filter(({ bone }) => groupBones.includes(bone)) .map(({ bone, value }) => ({ type: 'group-part', @@ -99,7 +102,7 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { value: total, } satisfies GroupLabel; }), - ...config.skeletonParts + ...skeleton.skeletonParts .filter( ({ bone }) => ![...BONE_MAPPING.values()].find((bones) => bones.includes(bone)) @@ -115,10 +118,10 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { }) satisfies BoneLabel ), ]; - }, [config, type]); + }, [skeleton, type]); useRPCPacket(RpcMessage.SkeletonConfigResponse, (data: SkeletonConfigResponseT) => { - setConfig(data); + setSkeleton(data); }); useEffect(() => { @@ -128,15 +131,15 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { return { bodyPartsGrouped, changeBoneValue: (params) => { - if (!config) return; + if (!skeleton) return; if (params.type === 'group') { const group = BONE_MAPPING.get(params.group); if (!group) throw 'invalid state - group should exist'; - const oldGroupTotal = config.skeletonParts + const oldGroupTotal = skeleton.skeletonParts .filter(({ bone }) => group.includes(bone)) .reduce((acc, cur) => cur.value + acc, 0); for (const part of group) { - const currentValue = config.skeletonParts.find(({ bone }) => bone === part); + const currentValue = skeleton.skeletonParts.find(({ bone }) => bone === part); if (!currentValue) throw 'invalid state - the bone should exists'; const currentRatio = currentValue.value / oldGroupTotal; sendRPCPacket( @@ -149,9 +152,9 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { if (params.type === 'group-part') { const group = BONE_MAPPING.get(params.group); if (!group) throw 'invalid state - group should exist'; - const part = config.skeletonParts.find(({ bone }) => bone === params.bone); + const part = skeleton.skeletonParts.find(({ bone }) => bone === params.bone); if (!part) throw 'invalid state - the part should exists'; - const oldGroupTotal = config.skeletonParts + const oldGroupTotal = skeleton.skeletonParts .filter(({ bone }) => group.includes(bone)) .reduce((acc, cur) => cur.value + acc, 0); let newValue = part.value + oldGroupTotal * params.newValue; // the new ratio is computed from the group size and not the bone @@ -170,7 +173,7 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { const signDiff = Math.sign(newValue - part.value); for (const part of group) { if (part === params.bone) continue; - const currentValue = config.skeletonParts.find(({ bone }) => bone === part); + const currentValue = skeleton.skeletonParts.find(({ bone }) => bone === part); if (!currentValue) throw 'invalid state - the bone should exists'; sendRPCPacket( RpcMessage.ChangeSkeletonConfigRequest, @@ -189,6 +192,7 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { ); } sendRPCPacket(RpcMessage.SkeletonConfigRequest, new SkeletonConfigRequestT()); + setConfig({ lastUsedProportions: 'manual' }); }, }; } diff --git a/gui/src/sounds/sounds.ts b/gui/src/sounds/sounds.ts index 2e55e5130..ab16f2d49 100644 --- a/gui/src/sounds/sounds.ts +++ b/gui/src/sounds/sounds.ts @@ -11,6 +11,9 @@ const tones: ValidNote[][] = [ const xylophone = new Xylophone(); const mew = createAudio('/sounds/mew.ogg'); +export const scaledProportionsClick = createAudio( + '/sounds/full-reset/full-click-1.ogg' +); const resetSounds: Record< ResetType, { @@ -115,6 +118,7 @@ export function handleResetSounds( ) { if (!resetSounds) throw 'sounds not loaded'; const sounds = resetSounds[resetType]; + if (!sounds) throw 'reset type does not have a reset sound: ' + resetType; if (status === ResetStatus.STARTED) { if (progress === 0) { diff --git a/gui/tailwind.config.ts b/gui/tailwind.config.ts index cf61eee43..55433ec99 100644 --- a/gui/tailwind.config.ts +++ b/gui/tailwind.config.ts @@ -177,7 +177,7 @@ const config = { 'md-max': { raw: 'not (min-width: 1100px)' }, lg: '1300px', xl: '1600px', - tall: { raw: '(min-height: 800px)' }, + tall: { raw: '(min-height: 860px)' }, }, extend: { colors: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a4cf380e..247679860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: classnames: specifier: ^2.5.1 version: 2.5.1 + convert: + specifier: ^5.12.0 + version: 5.13.1 flatbuffers: specifier: 22.10.26 version: 22.10.26 @@ -2097,6 +2100,9 @@ packages: resolution: {integrity: sha512-cj09EBuObp9gZNQCzc7hByQyrs6jVGE+o9kSJmeUoj+GiPiJvi5LYqEH/Hmme4+MTLHM+Ejtq+FChpjjEnsPdQ==} engines: {node: '>= 4'} + convert@5.13.1: + resolution: {integrity: sha512-LB7K75X/D7Xp9ZYhNrjcny8kr+xzlDcw/KK6lccXrHhxvr2E/LO/UtlYRZRdpAVb9xe5uEBY++uish8Rz5+9IQ==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -6847,6 +6853,8 @@ snapshots: convert-to-spaces@1.0.2: {} + convert@5.13.1: {} + create-require@1.1.1: {} cross-env@7.0.3: 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 98bf57725..c0fa7f2b8 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 @@ -3,7 +3,6 @@ package dev.slimevr.autobone.errors import dev.slimevr.autobone.AutoBoneStep import dev.slimevr.autobone.PoseFrameStep import dev.slimevr.autobone.errors.proportions.ProportionLimiter -import dev.slimevr.autobone.errors.proportions.RangeProportionLimiter import dev.slimevr.tracking.processor.HumanPoseManager import dev.slimevr.tracking.processor.config.SkeletonConfigManager import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets @@ -33,11 +32,23 @@ class BodyProportionError : IAutoBoneError { @JvmField var eyeHeightToHeightRatio = 0.936f - private val defaultHeight = SkeletonConfigManager.HEIGHT_OFFSETS.sumOf { it.defaultValue.toDouble() }.toFloat() - private fun makeLimiter(offset: SkeletonConfigOffsets, range: Float): RangeProportionLimiter = RangeProportionLimiter( - offset.defaultValue / defaultHeight, + val defaultHeight = SkeletonConfigManager.HEIGHT_OFFSETS.sumOf { + it.defaultValue.toDouble() + }.toFloat() + + private fun makeLimiter( + offset: SkeletonConfigOffsets, + range: Float, + scaleByHeight: Boolean = true, + ) = ProportionLimiter( + if (scaleByHeight) { + offset.defaultValue / defaultHeight + } else { + offset.defaultValue + }, offset, range, + scaleByHeight, ) // "Expected" are values from Drillis and Contini (1966) @@ -51,6 +62,7 @@ class BodyProportionError : IAutoBoneError { makeLimiter( SkeletonConfigOffsets.HEAD, 0.01f, + scaleByHeight = false, ), // Expected: 0.052 makeLimiter( @@ -60,6 +72,7 @@ class BodyProportionError : IAutoBoneError { makeLimiter( SkeletonConfigOffsets.SHOULDERS_WIDTH, 0.04f, + scaleByHeight = false, ), makeLimiter( SkeletonConfigOffsets.UPPER_ARM, @@ -89,6 +102,7 @@ class BodyProportionError : IAutoBoneError { makeLimiter( SkeletonConfigOffsets.HIPS_WIDTH, 0.04f, + scaleByHeight = false, ), // Expected: 0.245 makeLimiter( diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/HardProportionLimiter.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/HardProportionLimiter.kt deleted file mode 100644 index 81fca431e..000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/HardProportionLimiter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.slimevr.autobone.errors.proportions - -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets - -/** - * @param targetRatio The bone to height ratio to target - * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length - */ -open class HardProportionLimiter( - override val targetRatio: Float = 0f, - override val skeletonConfigOffset: SkeletonConfigOffsets, -) : ProportionLimiter { - override fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float { - val boneLength = humanPoseManager.getOffset(skeletonConfigOffset) - return targetRatio - boneLength / height - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt index f3fbbdf02..711a6f44b 100644 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt +++ b/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt @@ -2,9 +2,79 @@ package dev.slimevr.autobone.errors.proportions import dev.slimevr.tracking.processor.HumanPoseManager import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets +import kotlin.math.* -interface ProportionLimiter { - fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float +class ProportionLimiter { val targetRatio: Float val skeletonConfigOffset: SkeletonConfigOffsets + val scaleByHeight: Boolean + + val positiveRange: Float + val negativeRange: Float + + /** + * @param targetRatio The bone to height ratio to target + * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length + * @param range The range from the target ratio to accept (ex. 0.1) + * @param scaleByHeight True if the bone length will be scaled by the height + */ + constructor( + targetRatio: Float, + skeletonConfigOffset: SkeletonConfigOffsets, + range: Float, + scaleByHeight: Boolean = true, + ) { + this.targetRatio = targetRatio + this.skeletonConfigOffset = skeletonConfigOffset + this.scaleByHeight = scaleByHeight + + // Handle if someone puts in a negative value + val absRange = abs(range) + positiveRange = absRange + negativeRange = -absRange + } + + /** + * @param targetRatio The bone to height ratio to target + * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length + * @param positiveRange The positive range from the target ratio to accept + * (ex. 0.1) + * @param negativeRange The negative range from the target ratio to accept + * (ex. -0.1) + * @param scaleByHeight True if the bone length will be scaled by the height + */ + constructor( + targetRatio: Float, + skeletonConfigOffset: SkeletonConfigOffsets, + positiveRange: Float, + negativeRange: Float, + scaleByHeight: Boolean = true, + ) { + // If the positive range is less than the negative range, something is wrong + require(positiveRange >= negativeRange) { "positiveRange must not be less than negativeRange" } + + this.targetRatio = targetRatio + this.skeletonConfigOffset = skeletonConfigOffset + this.scaleByHeight = scaleByHeight + + this.positiveRange = positiveRange + this.negativeRange = negativeRange + } + + fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float { + val boneLength = humanPoseManager.getOffset(skeletonConfigOffset) + val ratioOffset = if (scaleByHeight) { + targetRatio - boneLength / height + } else { + targetRatio - boneLength + } + + // If the range is exceeded, return the offset from the range limit + if (ratioOffset > positiveRange) { + return ratioOffset - positiveRange + } else if (ratioOffset < negativeRange) { + return ratioOffset - negativeRange + } + return 0f + } } diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/RangeProportionLimiter.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/RangeProportionLimiter.kt deleted file mode 100644 index 732b6b61f..000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/RangeProportionLimiter.kt +++ /dev/null @@ -1,62 +0,0 @@ -package dev.slimevr.autobone.errors.proportions - -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import kotlin.math.* - -class RangeProportionLimiter : HardProportionLimiter { - private val targetPositiveRange: Float - private val targetNegativeRange: Float - - /** - * @param targetRatio The bone to height ratio to target - * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length - * @param range The range from the target ratio to accept (ex. 0.1) - */ - constructor( - targetRatio: Float, - skeletonConfigOffset: SkeletonConfigOffsets, - range: Float, - ) : super(targetRatio, skeletonConfigOffset) { - val absRange = abs(range) - - // Handle if someone puts in a negative value - targetPositiveRange = absRange - targetNegativeRange = -absRange - } - - /** - * @param targetRatio The bone to height ratio to target - * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length - * @param positiveRange The positive range from the target ratio to accept - * (ex. 0.1) - * @param negativeRange The negative range from the target ratio to accept - * (ex. -0.1) - */ - constructor( - targetRatio: Float, - skeletonConfigOffset: SkeletonConfigOffsets, - positiveRange: Float, - negativeRange: Float, - ) : super(targetRatio, skeletonConfigOffset) { - - // If the positive range is less than the negative range, something is - // wrong - require(positiveRange >= negativeRange) { "positiveRange must not be less than negativeRange" } - targetPositiveRange = positiveRange - targetNegativeRange = negativeRange - } - - override fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float { - val boneLength = humanPoseManager.getOffset(skeletonConfigOffset) - val ratioOffset = targetRatio - boneLength / height - - // If the range is exceeded, return the offset from the range limit - if (ratioOffset > targetPositiveRange) { - return ratioOffset - targetPositiveRange - } else if (ratioOffset < targetNegativeRange) { - return ratioOffset - targetNegativeRange - } - return 0f - } -} diff --git a/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt b/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt index 9ec9701bc..0b522011b 100644 --- a/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt +++ b/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt @@ -8,6 +8,7 @@ class ServerGuards { var canDoMounting: Boolean = false var canDoYawReset: Boolean = false + var canDoUserHeightCalibration: Boolean = false private val timer = Timer() private var mountingTimeoutTask: TimerTask? = null diff --git a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilderKotlin.kt b/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilderKotlin.kt index 755e20425..8600da05a 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilderKotlin.kt +++ b/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilderKotlin.kt @@ -41,5 +41,10 @@ object DataFeedBuilderKotlin { return StayAlignedTracker.endStayAlignedTracker(fbb) } - fun createServerGuard(fbb: FlatBufferBuilder, serverGuards: ServerGuards): Int = solarxr_protocol.data_feed.server.ServerGuards.createServerGuards(fbb, serverGuards.canDoMounting, serverGuards.canDoYawReset) + fun createServerGuard(fbb: FlatBufferBuilder, serverGuards: ServerGuards): Int = solarxr_protocol.data_feed.server.ServerGuards.createServerGuards( + fbb, + serverGuards.canDoMounting, + serverGuards.canDoYawReset, + serverGuards.canDoUserHeightCalibration, + ) } 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 873befeaf..cb246da09 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 @@ -51,6 +51,7 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler + server.apiConnections.forEach { conn -> + conn.send(fbb.dataBuffer()) + } + } + } +} 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 7cdec98b7..45ea6a054 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 @@ -458,33 +458,20 @@ class SkeletonConfigManager( } fun resetOffset(config: SkeletonConfigOffsets) { - when (config) { - SkeletonConfigOffsets.UPPER_ARM, - SkeletonConfigOffsets.LOWER_ARM, - SkeletonConfigOffsets.UPPER_CHEST, - SkeletonConfigOffsets.CHEST, - SkeletonConfigOffsets.WAIST, - SkeletonConfigOffsets.HIP, - SkeletonConfigOffsets.UPPER_LEG, - SkeletonConfigOffsets.LOWER_LEG, - -> { - 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( - config, - height * proportionLimiter.targetRatio, - ) - } else { - setOffset(config, null) - } - } else { // if floor level is incorrect - setOffset(config, null) - } + val height = humanPoseManager?.server?.configManager?.vrConfig?.skeleton?.userHeight ?: -1f + // Only scale if the height is within range + if (height > AutoBone.MIN_HEIGHT) { + val proportionLimiter = proportionLimitMap[config] + if (proportionLimiter != null && proportionLimiter.scaleByHeight) { + setOffset( + config, + height * proportionLimiter.targetRatio, + ) + } else { + setOffset(config, null) } - - else -> setOffset(config, null) + } else { + setOffset(config, null) } } 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 32e8e4f72..91a7d7603 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 @@ -215,6 +215,7 @@ class HumanSkeleton( var tapDetectionManager: TapDetectionManager? = null var localizer = Localizer(this) var ikSolver = IKSolver(headBone) + var userHeightCalibration: UserHeightCalibration? = null // Stay Aligned var trackerSkeleton = TrackerSkeleton(this) @@ -236,6 +237,7 @@ class HumanSkeleton( this, humanPoseManager, ) + userHeightCalibration = UserHeightCalibration(server, humanPoseManager) legTweaks.setConfig(server.configManager.vrConfig.legTweaks) localizer.setEnabled(humanPoseManager.getToggle(SkeletonConfigToggles.SELF_LOCALIZATION)) stayAlignedConfig = server.configManager.vrConfig.stayAlignedConfig @@ -474,6 +476,8 @@ class HumanSkeleton( // Update tap detection's trackers tapDetectionManager?.refresh() + userHeightCalibration?.checkTrackers() + // Rebuild Ik Solver ikSolver.buildChains(trackers) @@ -537,6 +541,7 @@ class HumanSkeleton( @VRServerThread fun updatePose() { tapDetectionManager?.update() + userHeightCalibration?.tick() StayAligned.adjustNextTracker(trackerSkeleton, stayAlignedConfig) diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt new file mode 100644 index 000000000..b87d64cf0 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt @@ -0,0 +1,317 @@ +package dev.slimevr.tracking.processor.skeleton + +import dev.slimevr.VRServer +import dev.slimevr.tracking.processor.HumanPoseManager +import dev.slimevr.tracking.trackers.Tracker +import dev.slimevr.tracking.trackers.TrackerPosition +import dev.slimevr.tracking.trackers.TrackerStatus +import io.github.axisangles.ktmath.Vector3 +import org.apache.commons.collections4.queue.CircularFifoQueue +import solarxr_protocol.rpc.UserHeightCalibrationStatus +import solarxr_protocol.rpc.UserHeightRecordingStatusResponseT +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sqrt + +/** + * @param positionSamples The list of Vector3 samples. + * @return The standard deviation as a Float. Returns Float.MAX_VALUE if the list is empty. + */ +fun calculatePositionStdDev(positionSamples: Collection): Float { + val sampleCount = positionSamples.size + if (sampleCount == 0) return Float.MAX_VALUE + + var sumX = 0f + var sumY = 0f + var sumZ = 0f + + for (pos in positionSamples) { + sumX += pos.x + sumY += pos.y + sumZ += pos.z + } + + val meanX = sumX / sampleCount + val meanY = sumY / sampleCount + val meanZ = sumZ / sampleCount + + var varianceSum = 0f + for (pos in positionSamples) { + val dx = pos.x - meanX + val dy = pos.y - meanY + val dz = pos.z - meanZ + // Square of the Euclidean distance from the mean + varianceSum += dx * dx + dy * dy + dz * dz + } + + val variance = varianceSum / sampleCount + return sqrt(variance) +} + +fun isHmdLeveled(hmd: Tracker, threshold: Double): Boolean { + val q = hmd.getRotation() + + val worldHmdUp = q.sandwich(Vector3.POS_Y) + val dotProduct = worldHmdUp.dot(Vector3.POS_Y) + return dotProduct >= threshold +} + +fun isControllerPointingDown(controller: Tracker, threshold: Double): Boolean { + val q = controller.getRawRotation() + val controllerForwardWorld = q.sandwich(Vector3.NEG_Z) + val worldDown = Vector3.NEG_Y + val dotProduct = controllerForwardWorld.dot(worldDown) + + return dotProduct >= threshold +} + +interface UserHeightCalibrationListener { + fun onStatusChange(status: UserHeightRecordingStatusResponseT) +} + +class UserHeightCalibration(val server: VRServer, val humanPoseManager: HumanPoseManager) { + var status = UserHeightCalibrationStatus.NONE + + var currentHeight = 0f + var currentFloorLevel = 0f + + var startTime = 0L + + private val hmdPositionSamples: CircularFifoQueue = CircularFifoQueue(MAX_SAMPLES) + private var heightStableStartTime: Long? = null + + private val floorPositionSamples: CircularFifoQueue = CircularFifoQueue(MAX_SAMPLES) + private var floorStableStartTime: Long? = null + + private val listeners: MutableList = CopyOnWriteArrayList() + private var hmd: Tracker? = null + + private val handTrackers: MutableList = mutableListOf() + + fun start() { + clear() + checkTrackers() + + if (!server.serverGuards.canDoUserHeightCalibration) { + return + } + + startTime = System.nanoTime() + + status = UserHeightCalibrationStatus.RECORDING_FLOOR + currentFloorLevel = Float.MAX_VALUE + + sendStatusUpdate() + } + + fun clear() { + status = UserHeightCalibrationStatus.NONE + hmdPositionSamples.clear() + floorPositionSamples.clear() + heightStableStartTime = null + floorStableStartTime = null + currentHeight = 0f + startTime = 0L + } + + init { + clear() + checkTrackers() + } + + fun checkTrackers() { + handTrackers.clear() + handTrackers.addAll( + server.allTrackers.filter { + ( + it.trackerPosition == TrackerPosition.LEFT_HAND || + it.trackerPosition == TrackerPosition.RIGHT_HAND + ) && + !it.isInternal && + it.hasPosition && + it.status == TrackerStatus.OK + }, + ) + + hmd = server.allTrackers.find { + it.trackerPosition == TrackerPosition.HEAD && + it.hasPosition && + !it.isInternal && + it.status == TrackerStatus.OK + } + server.serverGuards.canDoUserHeightCalibration = hmd != null && handTrackers.isNotEmpty() + + currentHeight = 0f + currentFloorLevel = 0f + } + + fun applyCalibration() { + server.configManager.vrConfig.skeleton.hmdHeight = currentHeight + server.configManager.vrConfig.skeleton.floorHeight = 0f + + server.humanPoseManager.resetOffsets() + server.humanPoseManager.saveConfig() + server.configManager.saveConfig() + + server.trackingChecklistManager.resetMountingCompleted = false + server.trackingChecklistManager.feetResetMountingCompleted = false + } + + fun tick() { + if (startTime == 0L) return + + val currentTime = System.nanoTime() + if (active && currentTime - startTime > TIMEOUT_TIME) { + status = UserHeightCalibrationStatus.ERROR_TIMEOUT + sendStatusUpdate() + return + } + + when (status) { + UserHeightCalibrationStatus.RECORDING_FLOOR, UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH -> recordFloor(currentTime) + UserHeightCalibrationStatus.WAITING_FOR_RISE, UserHeightCalibrationStatus.RECORDING_HEIGHT, UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK -> recordHeight(currentTime) + } + } + + private fun recordFloor(currentTime: Long) { + val lowestTracker = handTrackers.minByOrNull { it.position.y } + val currentLowestPos = lowestTracker?.position ?: return + + if (currentLowestPos.y > MAX_FLOOR_Y) { + floorStableStartTime = null + floorPositionSamples.clear() + return + } + + if (!isControllerPointingDown(lowestTracker, CONTROLLER_ANGLE_THRESHOLD)) { + status = UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH + floorStableStartTime = null + floorPositionSamples.clear() + sendStatusUpdate() + return + } + + floorPositionSamples.add(currentLowestPos) + currentFloorLevel = minOf(currentFloorLevel, currentLowestPos.y) + + if (floorPositionSamples.isAtFullCapacity) { + val isStable = calculatePositionStdDev(floorPositionSamples) <= CONTROLLER_POSITION_STD_DEV_THRESHOLD + + if (isStable) { + if (floorStableStartTime == null) { + floorStableStartTime = currentTime + } + + val stableDuration = currentTime - floorStableStartTime!! + if (stableDuration >= CONTROLLER_STABILITY_DURATION) { + status = UserHeightCalibrationStatus.WAITING_FOR_RISE + sendStatusUpdate() + } + } else { + floorStableStartTime = null + } + } + } + + private fun recordHeight(currentTime: Long) { + val localHmd = hmd ?: return + + val currentPos = localHmd.position + val relativeY = currentPos.y - currentFloorLevel + + if (relativeY <= HMD_RISE_THRESHOLD) { + status = UserHeightCalibrationStatus.WAITING_FOR_RISE + sendStatusUpdate() + hmdPositionSamples.clear() + heightStableStartTime = null + return + } + + if (currentHeight != relativeY) { + currentHeight = relativeY + sendStatusUpdate() + } + + if (!isHmdLeveled(localHmd, HEAD_ANGLE_THRESHOLD)) { + status = UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK + heightStableStartTime = null + hmdPositionSamples.clear() + sendStatusUpdate() + return + } + + status = UserHeightCalibrationStatus.RECORDING_HEIGHT + hmdPositionSamples.add(currentPos) + + if (hmdPositionSamples.isAtFullCapacity) { + val std = calculatePositionStdDev(hmdPositionSamples) + val isStable = std <= POSITION_STD_DEV_THRESHOLD + + if (isStable) { + if (heightStableStartTime == null) { + heightStableStartTime = currentTime + } + + val stableDuration = currentTime - heightStableStartTime!! + if (stableDuration >= HEAD_STABILITY_DURATION) { + status = if (currentHeight < 1.2f) { + UserHeightCalibrationStatus.ERROR_TOO_SMALL + } else if (currentHeight > 1.936f) { + UserHeightCalibrationStatus.ERROR_TOO_HIGH + } else { + UserHeightCalibrationStatus.DONE + } + + if (status == UserHeightCalibrationStatus.DONE) { + applyCalibration() + } + + sendStatusUpdate() + } + } else { + heightStableStartTime = null + } + } + } + + fun addListener(listener: UserHeightCalibrationListener) { + listeners.add(listener) + } + + fun removeListener(listener: UserHeightCalibrationListener) { + listeners.remove(listener) + } + + fun sendStatusUpdate() { + val res = UserHeightRecordingStatusResponseT().apply { + this.status = this@UserHeightCalibration.status + this.hmdHeight = this@UserHeightCalibration.currentHeight + } + listeners.forEach { it.onStatusChange(res) } + } + + val active: Boolean + get() { + return status == UserHeightCalibrationStatus.RECORDING_HEIGHT || status == UserHeightCalibrationStatus.RECORDING_FLOOR || status == UserHeightCalibrationStatus.WAITING_FOR_RISE || status == UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK + } + + companion object { + private const val MAX_SAMPLES = 100 + + private const val POSITION_STD_DEV_THRESHOLD = 0.003f + private const val HEAD_STABILITY_DURATION = 600_000_000f + + private const val CONTROLLER_POSITION_STD_DEV_THRESHOLD = 0.005f + private const val CONTROLLER_STABILITY_DURATION = 300_000_000f + + private const val MAX_FLOOR_Y = 0.10f + private const val HMD_RISE_THRESHOLD = 1.2f + + val HEAD_ANGLE_THRESHOLD = cos((PI / 180f) * 15f) + val CONTROLLER_ANGLE_THRESHOLD = cos((PI / 180f) * 45f) + + private const val TIMEOUT_TIME = 30_000_000_000f + } +} diff --git a/solarxr-protocol b/solarxr-protocol index 0dbad5b80..1fd32a450 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 0dbad5b803aa34c5d976519d260f8a5592506dc1 +Subproject commit 1fd32a4501b0ef7e79a1199f4f17fa80dcec0299