stay aligned wizard (#1442)

This commit is contained in:
lucas lelievre
2025-05-28 01:00:04 +02:00
committed by GitHub
parent a4c14aa23e
commit 419841d9f9
33 changed files with 972 additions and 596 deletions

View File

@@ -22,6 +22,7 @@
"@tauri-apps/plugin-os": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tweenjs/tween.js": "^25.0.0",
"@twemoji/svg": "^15.0.0",
"browser-fs-access": "^0.35.0",
"classnames": "^2.5.1",

View File

@@ -555,7 +555,7 @@ settings-stay_aligned-relaxed_poses-description = Stay Aligned uses your relaxed
settings-stay_aligned-relaxed_poses-standing = Adjust trackers while standing
settings-stay_aligned-relaxed_poses-sitting = Adjust trackers while sitting in a chair
settings-stay_aligned-relaxed_poses-flat = Adjust trackers while sitting on the floor, or lying on your back
settings-stay_aligned-relaxed_poses-detect_pose = Detect pose
settings-stay_aligned-relaxed_poses-save_pose = Save pose
settings-stay_aligned-relaxed_poses-reset_pose = Reset pose
settings-stay_aligned-debug-label = Debugging
settings-stay_aligned-debug-description = Please include your settings when reporting problems about Stay Aligned.
@@ -1111,8 +1111,9 @@ onboarding-automatic_mounting-mounting_reset-title = Mounting Reset
onboarding-automatic_mounting-mounting_reset-step-0 = 1. Squat in a "skiing" pose with your legs bent, your upper body tilted forwards, and your arms bent.
onboarding-automatic_mounting-mounting_reset-step-1 = 2. Press the "Reset Mounting" button and wait for 3 seconds before the trackers' mounting orientations will reset.
onboarding-automatic_mounting-preparation-title = Preparation
onboarding-automatic_mounting-preparation-step-0 = 1. Stand upright with your arms to your sides.
onboarding-automatic_mounting-preparation-step-1 = 2. Press the "Full Reset" button and wait for 3 seconds before the trackers will reset.
onboarding-automatic_mounting-preparation-v2-step-0 = 1. Press the "Full Reset" button.
onboarding-automatic_mounting-preparation-v2-step-1 = 2. Stand upright with your arms to your sides. Make sure to look forward.
onboarding-automatic_mounting-preparation-v2-step-2 = 3. Hold the position until the 3s timer ends.
onboarding-automatic_mounting-put_trackers_on-title = Put on your trackers
onboarding-automatic_mounting-put_trackers_on-description = To calibrate mounting orientations, 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_mounting-put_trackers_on-next = I have all my trackers on
@@ -1248,26 +1249,31 @@ onboarding-scaled_proportions-done-description = Your body proportions should no
## Stay Aligned setup
onboarding-stay_aligned-title = Stay Aligned
onboarding-stay_aligned-description = Configure Stay Aligned to keep your trackers aligned.
onboarding-stay_aligned-put_trackers_on-title = Put on your trackers
onboarding-stay_aligned-put_trackers_on-description = To save your resting poses, we'll use the trackers you just assigned. Put on all your trackers, you can see which are which in the figure to the right.
onboarding-stay_aligned-put_trackers_on-trackers_warning = You have fewer than 5 trackers currently connected and assigned! This is the minimum amount of trackers required for Stay Aligned to function properly.
onboarding-stay_aligned-put_trackers_on-next = I have all my trackers on
onboarding-stay_aligned-verify_mounting-title = Check your Mounting
onboarding-stay_aligned-verify_mounting-step-0 = Stay Aligned requires good mounting. Otherwise, you won't get a good experience with Stay Aligned.
onboarding-stay_aligned-verify_mounting-step-1 = 1. Move around while standing.
onboarding-stay_aligned-verify_mounting-step-2 = 2. Sit down and move your legs and feet.
onboarding-stay_aligned-verify_mounting-step-3 = 3. If your trackers aren't in the right place, restart the process.
onboarding-stay_aligned-verify_mounting-step-3 = 3. If your trackers aren't in the right place, press "Redo Mounting Calibration"
onboarding-stay_aligned-verify_mounting-redo_mounting = Redo Mounting calibration
onboarding-stay_aligned-preparation-title = Preparation
onboarding-stay_aligned-preparation-tip = Make sure to stand upright. You must be looking forward and your arms must be down to your sides.
onboarding-stay_aligned-relaxed_poses-standing-title = Relaxed Standing Pose
onboarding-stay_aligned-relaxed_poses-standing-step-0 = 1. Stand in a comfortable position. Relax!
onboarding-stay_aligned-relaxed_poses-standing-step-1 = 2. Check that your trackers match your body. If it does not match, you need to restart this flow.
onboarding-stay_aligned-relaxed_poses-standing-step-2 = 3. Press the "Detect pose" button.
onboarding-stay_aligned-relaxed_poses-sitting-title = Relaxed Sitting in Chair Pose
onboarding-stay_aligned-relaxed_poses-sitting-step-0 = 1. Sit in a comfortable position. Relax!
onboarding-stay_aligned-relaxed_poses-sitting-step-1 = 2. Check that your trackers match your body. If it does not match, you need to restart this flow.
onboarding-stay_aligned-relaxed_poses-sitting-step-2 = 3. Press the "Detect pose" button.
onboarding-stay_aligned-relaxed_poses-flat-title = Relaxed Sitting on Floor Pose
onboarding-stay_aligned-relaxed_poses-flat-step-0 = 1. Sit on the floor with your legs in front. Relax!
onboarding-stay_aligned-relaxed_poses-flat-step-1 = 2. Check that your trackers match your body. If it does not match, you need to restart this flow.
onboarding-stay_aligned-relaxed_poses-flat-step-2 = 3. Press the "Detect pose" button.
onboarding-stay_aligned-relaxed_poses-skip_step = Skip
onboarding-stay_aligned-done-title = Stay Aligned enabled!
onboarding-stay_aligned-done-description = Your Stay Aligned setup is complete!
onboarding-stay_aligned-done-description-2 = Setup is complete! You may restart the process if you want to re-calibrate the poses
onboarding-stay_aligned-previous_step = Previous
onboarding-stay_aligned-next_step = Next
onboarding-stay_aligned-restart = Restart

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -36,6 +36,16 @@ function ButtonContent({
);
}
export type ButtonProps = {
children?: ReactNode;
icon?: ReactNode;
variant: 'primary' | 'secondary' | 'tertiary' | 'quaternary';
to?: string;
loading?: boolean;
rounded?: boolean;
state?: any;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({
children,
variant,
@@ -46,15 +56,7 @@ export function Button({
icon,
rounded = false,
...props
}: {
children?: ReactNode;
icon?: ReactNode;
variant: 'primary' | 'secondary' | 'tertiary' | 'quaternary';
to?: string;
loading?: boolean;
rounded?: boolean;
state?: any;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
}: ButtonProps) {
const classes = useMemo(() => {
const variantsMap = {
primary: classNames({

View File

@@ -37,7 +37,7 @@ export function VerticalStep({
setTimeout(() => {
if (!refTop.current) return;
refTop.current.scrollIntoView({ behavior: 'smooth' });
}, 500);
}, 300);
}, [isSelected]);
useLayoutEffect(() => {
@@ -78,7 +78,7 @@ export function VerticalStep({
<div
style={{ height: !isSelected ? 0 : height }}
className={classNames('overflow-clip px-1', {
'duration-500 transition-[height]': shouldAnimate,
'duration-300 transition-[height]': shouldAnimate,
})}
>
<div ref={ref}>{children}</div>
@@ -88,12 +88,13 @@ export function VerticalStep({
);
}
type VerticalStepComponentType = FC<{
export type VerticalStepComponentProps = {
nextStep: () => void;
prevStep: () => void;
goTo: (id: string) => void;
isActive: boolean;
}>;
};
type VerticalStepComponentType = FC<VerticalStepComponentProps>;
export type VerticalStep = {
title: string;
@@ -101,7 +102,13 @@ export type VerticalStep = {
component: VerticalStepComponentType;
};
export default function VerticalStepper({ steps }: { steps: VerticalStep[] }) {
export default function VerticalStepper({
steps,
onStepChange,
}: {
steps: VerticalStep[];
onStepChange?: (index: number, id?: string) => void;
}) {
const [currStep, setStep] = useState(0);
const nextStep = () => {
@@ -121,6 +128,10 @@ export default function VerticalStepper({ steps }: { steps: VerticalStep[] }) {
setStep(step);
};
useEffect(() => {
onStepChange?.(currStep, steps[currStep].id);
}, [currStep]);
return (
<ol className="relative border-l border-gray-700 text-gray-400">
{steps.map(({ title, component: StepComponent }, index) => (

View File

@@ -1,9 +1,16 @@
export function CheckIcon({ size = 9 }: { size?: number }) {
export function CheckIcon({
size = 9,
className,
}: {
size?: number;
className?: string;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 9 7"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_548_761)">

View File

@@ -1,9 +1,16 @@
export function CrossIcon({ size = 20 }: { size: number }) {
export function CrossIcon({
size = 20,
className,
}: {
size?: number;
className?: string;
}) {
return (
<svg
viewBox="0 0 20 20"
width={size}
height={20}
height={size}
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path

View File

@@ -24,7 +24,9 @@ export function DoneStep({ variant }: { variant: 'onboarding' | 'alone' }) {
</Button>
)}
</div>
<SkeletonVisualizerWidget />
<div className="flex flex-grow max-h-[450px]">
<SkeletonVisualizerWidget />
</div>
</div>
);
}

View File

@@ -2,8 +2,9 @@ import { ResetType } from 'solarxr-protocol';
import { Button } from '@/components/commons/Button';
import { Typography } from '@/components/commons/Typography';
import { ResetButton } from '@/components/home/ResetButton';
import { useLocalization } from '@fluent/react';
import { useBreakpoint } from '@/hooks/breakpoint';
import { Localized, useLocalization } from '@fluent/react';
import { CrossIcon } from '@/components/commons/icon/CrossIcon';
import { CheckIcon } from '@/components/commons/icon/CheckIcon';
export function PreparationStep({
nextStep,
@@ -14,7 +15,6 @@ export function PreparationStep({
prevStep: () => void;
variant: 'onboarding' | 'alone';
}) {
const { isMobile } = useBreakpoint('mobile');
const { l10n } = useLocalization();
return (
@@ -25,27 +25,43 @@ export function PreparationStep({
{l10n.getString('onboarding-automatic_mounting-preparation-title')}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_mounting-preparation-step-0'
)}
</Typography>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_mounting-preparation-step-1'
)}
</Typography>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-0">
<Typography color="secondary"></Typography>
</Localized>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-1">
<Typography color="secondary"></Typography>
</Localized>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-2">
<Typography color="secondary"></Typography>
</Localized>
</div>
</div>
{isMobile && (
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-12">
<div className="grid grid-cols-3 py-4 gap-2">
<div className="flex flex-col bg-background-70 rounded-md relative max-h-64">
<CheckIcon className="md:w-14 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-success"></CheckIcon>
<img
src="/images/reset-pose.webp"
width={50}
src="/images/reset/FullResetPose.webp"
className="h-full object-contain"
alt="Reset position"
/>
</div>
)}
<div className="flex flex-col bg-background-70 rounded-md relative max-h-64">
<CheckIcon className="md:w-14 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-success"></CheckIcon>
<img
src="/images/reset/FullResetPoseSide.webp"
className="h-full object-contain"
alt="Reset position side"
/>
</div>
<div className="flex flex-col bg-background-70 rounded-md relative max-h-64">
<CrossIcon className="md:w-14 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-critical"></CrossIcon>
<img
src="/images/reset/FullResetPoseWrong.webp"
className="h-full object-contain"
alt="Reset position wrong"
/>
</div>
</div>
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
@@ -60,11 +76,6 @@ export function PreparationStep({
></ResetButton>
</div>
</div>
{!isMobile && (
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-12">
<img src="/images/reset-pose.webp" width={90} alt="Reset position" />
</div>
)}
</div>
);
}

View File

@@ -2,8 +2,9 @@ import { ResetType } from 'solarxr-protocol';
import { Button } from '@/components/commons/Button';
import { Typography } from '@/components/commons/Typography';
import { ResetButton } from '@/components/home/ResetButton';
import { useLocalization } from '@fluent/react';
import { useBreakpoint } from '@/hooks/breakpoint';
import { Localized, useLocalization } from '@fluent/react';
import { CheckIcon } from '@/components/commons/icon/CheckIcon';
import { CrossIcon } from '@/components/commons/icon/CrossIcon';
export function PreparationStep({
nextStep,
@@ -14,7 +15,6 @@ export function PreparationStep({
prevStep: () => void;
variant: 'onboarding' | 'alone';
}) {
const { isMobile } = useBreakpoint('mobile');
const { l10n } = useLocalization();
return (
@@ -25,27 +25,43 @@ export function PreparationStep({
{l10n.getString('onboarding-automatic_mounting-preparation-title')}
</Typography>
<div>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_mounting-preparation-step-0'
)}
</Typography>
<Typography color="secondary">
{l10n.getString(
'onboarding-automatic_mounting-preparation-step-1'
)}
</Typography>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-0">
<Typography color="secondary"></Typography>
</Localized>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-1">
<Typography color="secondary"></Typography>
</Localized>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-2">
<Typography color="secondary"></Typography>
</Localized>
</div>
</div>
{isMobile && (
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-12">
<div className="grid grid-cols-3 py-4 gap-2">
<div className="flex flex-col bg-background-70 rounded-md relative max-h-64">
<CheckIcon className="md:w-14 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-success"></CheckIcon>
<img
src="/images/reset-pose.webp"
width={100}
src="/images/reset/FullResetPose.webp"
className="h-full object-contain"
alt="Reset position"
/>
</div>
)}
<div className="flex flex-col bg-background-70 rounded-md relative max-h-64">
<CheckIcon className="md:w-14 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-success"></CheckIcon>
<img
src="/images/reset/FullResetPoseSide.webp"
className="h-full object-contain"
alt="Reset position side"
/>
</div>
<div className="flex flex-col bg-background-70 rounded-md relative max-h-64">
<CrossIcon className="md:w-14 sm:w-8 w-6 h-auto absolute top-2 right-2 fill-status-critical"></CrossIcon>
<img
src="/images/reset/FullResetPoseWrong.webp"
className="h-full object-contain"
alt="Reset position wrong"
/>
</div>
</div>
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
@@ -60,11 +76,6 @@ export function PreparationStep({
></ResetButton>
</div>
</div>
{!isMobile && (
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-12">
<img src="/images/reset-pose.webp" width={90} alt="Reset position" />
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,6 @@
import { useOnboarding } from '@/hooks/onboarding';
import { Typography } from '@/components/commons/Typography';
import { Step, StepperSlider } from '@/components/onboarding/StepperSlider';
import { DoneStep } from './stay-aligned-steps/Done';
import { useLocalization } from '@fluent/react';
import { autoMountingSteps } from '@/components/onboarding/pages/mounting/AutomaticMounting';
import {
FlatRelaxedPoseStep,
SittingRelaxedPoseStep,
@@ -11,8 +8,18 @@ import {
} from './stay-aligned-steps/RelaxedPoseSteps';
import { EnableStayAlignedRequestT, RpcMessage } from 'solarxr-protocol';
import { RPCPacketType, useWebsocketAPI } from '@/hooks/websocket-api';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import VerticalStepper from '@/components/commons/VerticalStepper';
import { useBreakpoint } from '@/hooks/breakpoint';
import {
SkeletonPreviewView,
SkeletonVisualizerWidget,
} from '@/components/widgets/SkeletonVisualizerWidget';
import { Vector3 } from 'three';
import { Easing } from '@tweenjs/tween.js';
import { VerifyMountingStep } from './stay-aligned-steps/VerifyMounting';
import { PutTrackersOnStep } from './stay-aligned-steps/PutTrackersOnStep';
import { PreparationStep } from './stay-aligned-steps/PreparationStep';
export function enableStayAligned(
enable: boolean,
@@ -23,28 +30,108 @@ export function enableStayAligned(
sendRPCPacket(RpcMessage.EnableStayAlignedRequest, req);
}
const steps: Step[] = [
...autoMountingSteps,
{ type: 'numbered', component: VerifyMountingStep },
{ type: 'numbered', component: StandingRelaxedPoseStep },
{ type: 'numbered', component: SittingRelaxedPoseStep },
{ type: 'numbered', component: FlatRelaxedPoseStep },
{ type: 'fullsize', component: DoneStep },
];
export function StayAlignedSetup() {
const { l10n } = useLocalization();
const { state } = useOnboarding();
const { isMobile } = useBreakpoint('mobile');
const { sendRPCPacket } = useWebsocketAPI();
const viewsRef = useRef<{
cam1?: SkeletonPreviewView;
cam2?: SkeletonPreviewView;
}>({});
useEffect(() => {
// Disable Stay Aligned as soon as we enter the setup flow so that we don't
// adjust the trackers while trying to set up the feature
enableStayAligned(false, sendRPCPacket);
}, []);
const updateCamSizes = () => {
const views = viewsRef.current;
if (!views.cam1 || !views.cam2) return;
if (!views.cam2.hidden) {
if (isMobile) {
views.cam1.height = 1;
views.cam2.height = 1;
views.cam2.width = 0.5;
views.cam1.width = 0.5;
views.cam1.bottom = 0;
views.cam2.bottom = 0;
views.cam2.left = 0.5;
} else {
views.cam1.height = 0.5;
views.cam2.height = 0.5;
views.cam2.width = 1;
views.cam1.width = 1;
views.cam1.bottom = 0;
views.cam2.bottom = 0.5;
views.cam2.left = 0;
}
} else {
views.cam1.height = 1;
views.cam2.height = 1;
views.cam2.width = 1;
views.cam1.width = 1;
views.cam1.bottom = 0;
views.cam2.bottom = 0;
views.cam2.left = 0;
}
};
const onStepChange = (index: number, id?: string) => {
if (id === 'start') {
enableStayAligned(false, sendRPCPacket);
}
const views = viewsRef.current;
if (!views.cam1 || !views.cam2) return;
switch (id) {
case 'standing': {
views.cam2.hidden = true;
views.cam1.tween
.stop()
.to(new Vector3(0, 1, -6), 500)
.easing(Easing.Quadratic.InOut)
.startFromCurrentValues();
break;
}
case 'flat':
case 'sitting': {
views.cam2.hidden = false;
views.cam1.tween
.stop()
.to(new Vector3(-5, 1, -0), 500)
.easing(Easing.Quadratic.InOut)
.startFromCurrentValues();
views.cam2.tween
.stop()
.to(new Vector3(0, 4, -0.2), 500)
.easing(Easing.Quadratic.InOut)
.startFromCurrentValues();
break;
}
default: {
views.cam2.hidden = true;
views.cam1.tween
.stop()
.to(new Vector3(3, 2.5, -3), 1000)
.easing(Easing.Quadratic.InOut)
.startFromCurrentValues();
break;
}
}
updateCamSizes();
};
useEffect(() => {
updateCamSizes();
}, [isMobile]);
return (
<div className="flex flex-col gap-2 h-full items-center w-full xs:justify-center relative overflow-y-auto overflow-x-hidden px-4 pb-4">
<div className="flex flex-col w-full h-full xs:justify-center xs:max-w-3xl gap-5">
<div className="h-full w-full flex gap-2 mobile:flex-col bg-background-80">
<div className="bg-background-70 rounded-md flex-grow p-4 overflow-y-auto">
<div className="flex flex-col xs:max-w-lg gap-3">
<Typography variant="main-title">
{l10n.getString('onboarding-stay_aligned-title')}
@@ -53,13 +140,90 @@ export function StayAlignedSetup() {
{l10n.getString('onboarding-stay_aligned-description')}
</Typography>
</div>
<div className="flex pb-4">
<StepperSlider
variant={state.alonePage ? 'alone' : 'onboarding'}
steps={steps}
/>
<div className="flex pl-4 pt-4">
<VerticalStepper
onStepChange={onStepChange}
steps={[
{
title: l10n.getString(
'onboarding-stay_aligned-put_trackers_on-title'
),
component: PutTrackersOnStep,
id: 'start',
},
{
title: l10n.getString(
'onboarding-stay_aligned-preparation-title'
),
component: PreparationStep,
},
{
title: l10n.getString(
'onboarding-stay_aligned-verify_mounting-title'
),
component: VerifyMountingStep,
},
{
title: l10n.getString(
'onboarding-stay_aligned-relaxed_poses-standing-title'
),
component: StandingRelaxedPoseStep,
id: 'standing',
},
{
title: l10n.getString(
'onboarding-stay_aligned-relaxed_poses-sitting-title'
),
component: SittingRelaxedPoseStep,
id: 'sitting',
},
{
title: l10n.getString(
'onboarding-stay_aligned-relaxed_poses-flat-title'
),
component: FlatRelaxedPoseStep,
id: 'flat',
},
{
title: l10n.getString('onboarding-stay_aligned-done-title'),
component: DoneStep,
},
]}
></VerticalStepper>
</div>
</div>
<div className="bg-background-70 rounded-md xs:max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg w-full mobile:h-[30%]">
<SkeletonVisualizerWidget
onInit={(context) => {
viewsRef.current.cam1 = 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, 0);
const scale = Math.max(1.8, newHeight) / 1.8;
v.camera.zoom = 1 / scale;
},
});
viewsRef.current.cam2 = context.addView({
left: 0,
bottom: 0.5,
width: 1,
height: 0.5,
hidden: true,
position: new Vector3(3, 2.5, -3),
onHeightChange(v, newHeight) {
v.controls.target.set(0, newHeight / 2, 0);
const scale = Math.max(1.8, newHeight) / 1.8;
v.camera.zoom = 1 / scale;
},
});
}}
></SkeletonVisualizerWidget>
</div>
</div>
);
}

View File

@@ -1,45 +1,31 @@
import { Button } from '@/components/commons/Button';
import { Typography } from '@/components/commons/Typography';
import { SkeletonVisualizerWidget } from '@/components/widgets/SkeletonVisualizerWidget';
import { useLocalization } from '@fluent/react';
export function DoneStep({
resetSteps,
variant,
}: {
resetSteps: () => void;
variant: 'onboarding' | 'alone';
}) {
const { l10n } = useLocalization();
import { VerticalStepComponentProps } from '@/components/commons/VerticalStepper';
import { Localized } from '@fluent/react';
export function DoneStep({ goTo }: VerticalStepComponentProps) {
return (
<div className="flex flex-col items-center w-full justify-center gap-5">
<div className="flex gap-1 flex-col justify-center items-center">
<Typography variant="section-title">
{l10n.getString('onboarding-stay_aligned-done-title')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-stay_aligned-done-description')}
</Typography>
<div className="flex flex-col items-center w-full justify-center gap-5 pt-2">
<div className="flex gap-1 flex-col justify-center items-center pt-10">
<Localized id="onboarding-stay_aligned-done-description">
<Typography variant="main-title"></Typography>
</Localized>
<Localized id="onboarding-stay_aligned-done-description-2">
<Typography color="secondary"></Typography>
</Localized>
</div>
<div className="flex gap-3">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
onClick={resetSteps}
>
{l10n.getString('onboarding-stay_aligned-restart')}
</Button>
<Button
variant="primary"
to="/settings/trackers"
state={{ scrollTo: 'stayaligned' }}
>
{l10n.getString('onboarding-stay_aligned-done')}
</Button>
<div className="flex gap-3 justify-between">
<Localized id="onboarding-stay_aligned-restart">
<Button variant={'secondary'} onClick={() => goTo('start')}></Button>
</Localized>
<Localized id="onboarding-stay_aligned-done">
<Button
variant="primary"
to="/settings/trackers"
state={{ scrollTo: 'stayaligned' }}
></Button>
</Localized>
</div>
<SkeletonVisualizerWidget />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Button } from '@/components/commons/Button';
import { CheckIcon } from '@/components/commons/icon/CheckIcon';
import { CrossIcon } from '@/components/commons/icon/CrossIcon';
import { TipBox } from '@/components/commons/TipBox';
import { Typography } from '@/components/commons/Typography';
import { VerticalStepComponentProps } from '@/components/commons/VerticalStepper';
import { ResetButton } from '@/components/home/ResetButton';
import { Localized } from '@fluent/react';
import { ResetType } from 'solarxr-protocol';
export function PreparationStep({
nextStep,
prevStep,
}: VerticalStepComponentProps) {
return (
<div className="flex flex-col flex-grow justify-between pt-2 gap-2">
<div className="flex flex-col gap-1">
<Localized id="onboarding-automatic_mounting-preparation-v2-step-0">
<Typography color="secondary"></Typography>
</Localized>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-1">
<Typography color="secondary"></Typography>
</Localized>
<Localized id="onboarding-automatic_mounting-preparation-v2-step-2">
<Typography color="secondary"></Typography>
</Localized>
</div>
<Localized id="onboarding-stay_aligned-preparation-tip">
<TipBox>TIP</TipBox>
</Localized>
<div className="grid grid-cols-3 py-4 gap-2">
<div className="flex flex-col bg-background-60 rounded-md relative">
<CheckIcon className="md:w-20 sm:w-10 w-6 h-auto absolute top-2 right-2 fill-status-success"></CheckIcon>
<img src="/images/reset/FullResetPose.webp" alt="Reset position" />
</div>
<div className="flex flex-col bg-background-60 rounded-md relative">
<CheckIcon className="md:w-20 sm:w-10 w-6 h-auto absolute top-2 right-2 fill-status-success"></CheckIcon>
<img
src="/images/reset/FullResetPoseSide.webp"
alt="Reset position side"
/>
</div>
<div className="flex flex-col bg-background-60 rounded-md relative">
<CrossIcon className="md:w-20 sm:w-10 w-6 h-auto absolute top-2 right-2 fill-status-critical"></CrossIcon>
<img
src="/images/reset/FullResetPoseWrong.webp"
alt="Reset position wrong"
/>
</div>
</div>
<div className="flex gap-3 justify-between">
<Localized id="onboarding-stay_aligned-previous_step">
<Button variant={'secondary'} onClick={prevStep}></Button>
</Localized>
<ResetButton
size="small"
type={ResetType.Full}
onReseted={nextStep}
></ResetButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { BodyDisplay } from '@/components/commons/BodyDisplay';
import { Button } from '@/components/commons/Button';
import { TipBox, WarningBox } from '@/components/commons/TipBox';
import { Typography } from '@/components/commons/Typography';
import { VerticalStepComponentProps } from '@/components/commons/VerticalStepper';
import { assignedTrackersAtom } from '@/store/app-store';
import { Localized } from '@fluent/react';
import { useAtomValue } from 'jotai';
export function PutTrackersOnStep({ nextStep }: VerticalStepComponentProps) {
const assignedTrackers = useAtomValue(assignedTrackersAtom);
// Keep the button while in dev
const canContinue = assignedTrackers.length >= 5 || import.meta.env.DEV;
return (
<div className="flex flex-col w-full pt-2">
<div className="flex flex-col flex-grow gap-2">
<div className="flex flex-grow flex-col gap-4">
<div>
<Localized id="onboarding-stay_aligned-put_trackers_on-description">
<Typography color="secondary"></Typography>
</Localized>
</div>
<div className="flex">
<Localized id="tips-find_tracker">
<TipBox>Tip</TipBox>
</Localized>
</div>
{assignedTrackers.length < 5 && (
<div className="flex">
<Localized id="onboarding-stay_aligned-put_trackers_on-trackers_warning">
<WarningBox>Warning</WarningBox>
</Localized>
</div>
)}
</div>
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-16">
<BodyDisplay
trackers={assignedTrackers}
width={150}
dotsSize={15}
variant="dots"
hideUnassigned={true}
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex gap-3 justify-end">
<Localized id="onboarding-stay_aligned-put_trackers_on-next">
<Button
variant="primary"
onClick={nextStep}
disabled={!canContinue}
></Button>
</Localized>
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,110 +8,122 @@ import { useLocalization } from '@fluent/react';
import { StayAlignedRelaxedPose } from 'solarxr-protocol';
import { enableStayAligned } from '@/components/onboarding/pages/stay-aligned/StayAlignedSetup';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { VerticalStepComponentProps } from '@/components/commons/VerticalStepper';
import { ReactNode } from 'react';
function makeRelaxedPoseStep(
titleKey: string,
descriptionKeys: string[],
imageUrl: string,
relaxedPose: StayAlignedRelaxedPose,
lastStep: boolean
) {
return ({
nextStep,
prevStep,
variant,
}: {
nextStep: () => void;
prevStep: () => void;
variant: 'onboarding' | 'alone';
}) => {
const { l10n } = useLocalization();
const { sendRPCPacket } = useWebsocketAPI();
return (
<div className="flex mobile:flex-col">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<Typography variant="main-title" bold>
{l10n.getString(titleKey)}
function PosePage({
nextStep,
prevStep,
descriptionKeys,
children,
relaxedPose,
lastStep = false,
}: VerticalStepComponentProps & {
descriptionKeys: string[];
children: ReactNode;
relaxedPose: StayAlignedRelaxedPose;
lastStep?: boolean;
}) {
const { l10n } = useLocalization();
const { sendRPCPacket } = useWebsocketAPI();
return (
<div className="flex flex-col pt-2">
<div className="flex flex-col gap-2">
{descriptionKeys.map((descriptionKey) => (
<Typography color="secondary">
{l10n.getString(descriptionKey)}
</Typography>
<div className="flex flex-col gap-2">
{descriptionKeys.map((descriptionKey) => (
<Typography color="secondary">
{l10n.getString(descriptionKey)}
</Typography>
))}
</div>
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
onClick={prevStep}
>
{l10n.getString('onboarding-stay_aligned-previous_step')}
</Button>
<DetectRelaxedPoseButton
onClick={() => {
if (lastStep) {
enableStayAligned(true, sendRPCPacket);
}
nextStep();
}}
pose={relaxedPose}
/>
<ResetRelaxedPoseButton
onClick={() => {
if (lastStep) {
enableStayAligned(true, sendRPCPacket);
}
nextStep();
}}
pose={relaxedPose}
>
{l10n.getString(
'onboarding-stay_aligned-relaxed_poses-skip_step'
)}
</ResetRelaxedPoseButton>
</div>
</div>
<div className="flex flex-col pt-1 items-center fill-background-50 justify-center px-12">
<img src={imageUrl} width={200} alt="Reset position" />
))}
</div>
<div className="flex pt-1 items-center fill-background-50 justify-center px-12">
{children}
</div>
<div className="flex justify-between">
<Button variant={'secondary'} onClick={prevStep}>
{l10n.getString('onboarding-stay_aligned-previous_step')}
</Button>
<div className="flex gap-2">
<ResetRelaxedPoseButton
variant="secondary"
onClick={() => {
if (lastStep) {
enableStayAligned(true, sendRPCPacket);
}
nextStep();
}}
pose={relaxedPose}
>
{l10n.getString('onboarding-stay_aligned-relaxed_poses-skip_step')}
</ResetRelaxedPoseButton>
<DetectRelaxedPoseButton
onClick={() => {
if (lastStep) {
enableStayAligned(true, sendRPCPacket);
}
nextStep();
}}
pose={relaxedPose}
/>
</div>
</div>
);
};
</div>
);
}
export const StandingRelaxedPoseStep = makeRelaxedPoseStep(
'onboarding-stay_aligned-relaxed_poses-standing-title',
[
'onboarding-stay_aligned-relaxed_poses-standing-step-0',
'onboarding-stay_aligned-relaxed_poses-standing-step-1',
'onboarding-stay_aligned-relaxed_poses-standing-step-2',
],
'/images/relaxed_pose_standing.webp',
StayAlignedRelaxedPose.STANDING,
false
export const StandingRelaxedPoseStep = (
verticalStepProps: VerticalStepComponentProps
) => (
<PosePage
{...verticalStepProps}
descriptionKeys={[
'onboarding-stay_aligned-relaxed_poses-standing-step-0',
'onboarding-stay_aligned-relaxed_poses-standing-step-2',
]}
relaxedPose={StayAlignedRelaxedPose.STANDING}
>
<img
src={'/images/stay-aligned/StayAlignedStanding.webp'}
width={300}
alt="Reset position"
/>
</PosePage>
);
export const SittingRelaxedPoseStep = makeRelaxedPoseStep(
'onboarding-stay_aligned-relaxed_poses-sitting-title',
[
'onboarding-stay_aligned-relaxed_poses-sitting-step-0',
'onboarding-stay_aligned-relaxed_poses-sitting-step-1',
'onboarding-stay_aligned-relaxed_poses-sitting-step-2',
],
'/images/relaxed_pose_sitting.webp',
StayAlignedRelaxedPose.SITTING,
false
export const SittingRelaxedPoseStep = (
verticalStepProps: VerticalStepComponentProps
) => (
<PosePage
{...verticalStepProps}
descriptionKeys={[
'onboarding-stay_aligned-relaxed_poses-sitting-step-0',
'onboarding-stay_aligned-relaxed_poses-sitting-step-2',
]}
relaxedPose={StayAlignedRelaxedPose.SITTING}
>
<img
src={'/images/stay-aligned/StayAlignedSitting.webp'}
width={300}
alt="Reset position"
/>
</PosePage>
);
export const FlatRelaxedPoseStep = makeRelaxedPoseStep(
'onboarding-stay_aligned-relaxed_poses-flat-title',
[
'onboarding-stay_aligned-relaxed_poses-flat-step-0',
'onboarding-stay_aligned-relaxed_poses-flat-step-1',
'onboarding-stay_aligned-relaxed_poses-flat-step-2',
],
'/images/relaxed_pose_flat.webp',
StayAlignedRelaxedPose.FLAT,
true
export const FlatRelaxedPoseStep = (
verticalStepProps: VerticalStepComponentProps
) => (
<PosePage
{...verticalStepProps}
descriptionKeys={[
'onboarding-stay_aligned-relaxed_poses-flat-step-0',
'onboarding-stay_aligned-relaxed_poses-flat-step-2',
]}
relaxedPose={StayAlignedRelaxedPose.FLAT}
lastStep
>
<img
src={'/images/stay-aligned/StayAlignedFloor.webp'}
width={600}
alt="Reset position"
/>
</PosePage>
);

View File

@@ -1,48 +1,43 @@
import { Button } from '@/components/commons/Button';
import { Typography } from '@/components/commons/Typography';
import { useLocalization } from '@fluent/react';
import { VerticalStepComponentProps } from '@/components/commons/VerticalStepper';
import { Localized } from '@fluent/react';
export function VerifyMountingStep({
nextStep,
resetSteps,
variant,
}: {
nextStep: () => void;
resetSteps: () => void;
variant: 'onboarding' | 'alone';
}) {
const { l10n } = useLocalization();
prevStep,
}: VerticalStepComponentProps) {
return (
<div className="flex mobile:flex-col">
<div className="flex flex-grow flex-col gap-4 max-w-sm">
<Typography variant="main-title" bold>
{l10n.getString('onboarding-stay_aligned-verify_mounting-title')}
</Typography>
<div className="flex flex-col gap-2">
<Typography color="secondary">
{l10n.getString('onboarding-stay_aligned-verify_mounting-step-0')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-stay_aligned-verify_mounting-step-1')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-stay_aligned-verify_mounting-step-2')}
</Typography>
<Typography color="secondary">
{l10n.getString('onboarding-stay_aligned-verify_mounting-step-3')}
</Typography>
</div>
<div className="flex gap-3 mobile:justify-between">
<Button
variant={variant === 'onboarding' ? 'secondary' : 'tertiary'}
onClick={resetSteps}
>
{l10n.getString('onboarding-stay_aligned-restart')}
</Button>
<Button variant="primary" onClick={nextStep}>
{l10n.getString('onboarding-stay_aligned-next_step')}
</Button>
<div className="flex flex-grow flex-col gap-4">
<div className="flex flex-col gap-2">
<Localized id="onboarding-stay_aligned-verify_mounting-step-0">
<Typography color="secondary" />
</Localized>
<Localized id="onboarding-stay_aligned-verify_mounting-step-1">
<Typography color="secondary" />
</Localized>
<Localized id="onboarding-stay_aligned-verify_mounting-step-2">
<Typography color="secondary" />
</Localized>
<Localized id="onboarding-stay_aligned-verify_mounting-step-3">
<Typography color="secondary" />
</Localized>
</div>
<div className="flex gap-3 justify-between">
<Localized id="onboarding-stay_aligned-previous_step">
<Button variant="secondary" onClick={prevStep}></Button>
</Localized>
<div className="flex gap-2">
<Localized id="onboarding-stay_aligned-verify_mounting-redo_mounting">
<Button
variant={'secondary'}
to="/onboarding/mounting/choose"
></Button>
</Localized>
<Localized id="onboarding-stay_aligned-next_step">
<Button variant="primary" onClick={nextStep}></Button>
</Localized>
</div>
</div>
</div>

View File

@@ -1,72 +0,0 @@
import { Button } from '@/components/commons/Button';
import { WarningBox } from '@/components/commons/TipBox';
import { Localized, useLocalization } from '@fluent/react';
import { BaseModal } from '@/components/commons/BaseModal';
import ReactModal from 'react-modal';
export function DriftCompensationModal({
isOpen = true,
onClose,
accept,
...props
}: {
/**
* Is the parent/sibling component opened?
*/
isOpen: boolean;
/**
* Function to trigger when the warning hasn't been accepted
*/
onClose: () => void;
/**
* Function when you press `I understand`
*/
accept: () => void;
} & ReactModal.Props) {
const { l10n } = useLocalization();
return (
<BaseModal
isOpen={isOpen}
shouldCloseOnOverlayClick
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="settings-general-tracker_mechanics-drift_compensation_warning"
elems={{ b: <b></b> }}
>
<WarningBox>
<b>Warning:</b> Drift compensation should only be used if you find
you need to reset very often (~5-10 minutes).
<br />
Some IMUs prone to frequent resets include: Joy-Cons, owoTrack,
and MPUs (without recent firmware).
</WarningBox>
</Localized>
<div className="flex flex-row gap-3 pt-5 place-content-center">
<Button variant="primary" onClick={onClose}>
{l10n.getString(
'settings-general-tracker_mechanics-drift_compensation_warning-cancel'
)}
</Button>
<Button
variant="tertiary"
onClick={() => {
accept();
}}
>
{l10n.getString(
'settings-general-tracker_mechanics-drift_compensation_warning-done'
)}
</Button>
</div>
</div>
</div>
</BaseModal>
);
}

View File

@@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { DefaultValues, useForm } from 'react-hook-form';
import {
ChangeSettingsRequestT,
DriftCompensationSettingsT,
FilteringSettingsT,
FilteringType,
LegTweaksSettingsT,
@@ -32,7 +31,6 @@ import {
} from '@/components/settings/SettingsPageLayout';
import { HandsWarningModal } from '@/components/settings/HandsWarningModal';
import { MagnetometerToggleSetting } from './MagnetometerToggleSetting';
import { DriftCompensationModal } from '@/components/settings/DriftCompensationModal';
import {
defaultStayAlignedSettings,
StayAlignedSettings,
@@ -59,12 +57,6 @@ export type SettingsForm = {
type: number;
amount: number;
};
driftCompensation: {
enabled: boolean;
prediction: boolean;
amount: number;
maxResets: number;
};
toggles: {
extendedSpine: boolean;
extendedPelvis: boolean;
@@ -151,12 +143,6 @@ const defaultValues: SettingsForm = {
interpKneeAnkle: 0.2,
},
filtering: { amount: 0.1, type: FilteringType.NONE },
driftCompensation: {
enabled: false,
prediction: false,
amount: 0.1,
maxResets: 1,
},
tapDetection: {
mountingResetEnabled: false,
yawResetEnabled: false,
@@ -299,13 +285,6 @@ export function GeneralSettings() {
filtering.amount = values.filtering.amount;
settings.filtering = filtering;
const driftCompensation = new DriftCompensationSettingsT();
driftCompensation.enabled = values.driftCompensation.enabled;
driftCompensation.prediction = values.driftCompensation.prediction;
driftCompensation.amount = values.driftCompensation.amount;
driftCompensation.maxResets = values.driftCompensation.maxResets;
settings.driftCompensation = driftCompensation;
settings.stayAligned = serializeStayAlignedSettings(values.stayAligned);
if (values.resetsSettings) {
@@ -344,10 +323,6 @@ export function GeneralSettings() {
formData.filtering = settings.filtering;
}
if (settings.driftCompensation) {
formData.driftCompensation = settings.driftCompensation;
}
if (settings.steamVrTrackers) {
formData.trackers = settings.steamVrTrackers;
if (
@@ -463,8 +438,6 @@ export function GeneralSettings() {
// }
// }, [state]);
const [showDriftCompWarning, setShowDriftCompWarning] = useState(false);
return (
<SettingsPageLayout>
<HandsWarningModal
@@ -703,102 +676,6 @@ export function GeneralSettings() {
step={0.1}
/>
</div>
<div className="flex flex-col pt-4 pb-4"></div>
<Typography bold>
{l10n.getString(
'settings-general-tracker_mechanics-drift_compensation'
)}
</Typography>
<div className="flex flex-col pt-2 pb-4">
{l10n
.getString(
'settings-general-tracker_mechanics-drift_compensation-description'
)
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</div>
<CheckBox
variant="toggle"
outlined
control={control}
name="driftCompensation.enabled"
label={l10n.getString(
'settings-general-tracker_mechanics-drift_compensation-enabled-label'
)}
onClick={() => {
if (getValues('driftCompensation.enabled')) {
return;
}
setShowDriftCompWarning(true);
}}
/>
<div className="flex flex-col pt-2 pb-4"></div>
<Typography bold>
{l10n.getString(
'settings-general-tracker_mechanics-drift_compensation-prediction'
)}
</Typography>
<div className="flex flex-col pt-2 pb-4">
{l10n
.getString(
'settings-general-tracker_mechanics-drift_compensation-prediction-description'
)
.split('\n')
.map((line, i) => (
<Typography color="secondary" key={i}>
{line}
</Typography>
))}
</div>
<CheckBox
variant="toggle"
outlined
control={control}
name="driftCompensation.prediction"
label={l10n.getString(
'settings-general-tracker_mechanics-drift_compensation-prediction-label'
)}
/>
<DriftCompensationModal
accept={() => {
setShowDriftCompWarning(false);
}}
onClose={() => {
setShowDriftCompWarning(false);
setValue('driftCompensation.enabled', false);
}}
isOpen={showDriftCompWarning}
></DriftCompensationModal>
<div className="flex gap-5 pt-5 md:flex-row flex-col">
<NumberSelector
control={control}
name="driftCompensation.amount"
label={l10n.getString(
'settings-general-tracker_mechanics-drift_compensation-amount-label'
)}
valueLabelFormat={(value) => percentageFormat.format(value)}
min={0.1}
max={1.0}
step={0.1}
/>
</div>
<div className="flex gap-5 pt-5 md:flex-row flex-col">
<NumberSelector
control={control}
name="driftCompensation.maxResets"
label={l10n.getString(
'settings-general-tracker_mechanics-drift_compensation-max_resets-label'
)}
min={1}
max={25}
step={1}
/>
</div>
<div className="flex gap-5 pt-5 md:flex-row flex-col">
<NumberSelector
control={control}

View File

@@ -122,7 +122,6 @@ ${trackers.map((t) => {
OTHER
=====
Drift compensation: ${values.driftCompensation.enabled ? 'true <------------ WTF' : 'false'}
Filtering: type=${values.filtering.type} amount=${numberFormat.format(values.filtering.amount)}
Enforce constraints: ${boolify(values.toggles.enforceConstraints)}
Skating correction: ${boolify(values.toggles.skatingCorrection)}
@@ -190,14 +189,7 @@ export function StayAlignedSettings({
<Typography bold>
{l10n.getString('settings-stay_aligned-general-label')}
</Typography>
{values.stayAligned.enabled && values.driftCompensation.enabled && (
<div className="mt-2">
{l10n.getString(
'settings-stay_aligned-warnings-drift_compensation'
)}
</div>
)}
<div className="grid sm:grid-cols-1 gap-3 mt-2">
<div className="grid sm:grid-cols-2 gap-3 mt-2">
<CheckBox
variant="toggle"
outlined

View File

@@ -6,7 +6,7 @@ import {
} from 'solarxr-protocol';
import { useWebsocketAPI } from '@/hooks/websocket-api';
import { MouseEventHandler } from 'react';
import { Button } from '@/components/commons/Button';
import { Button, ButtonProps } from '@/components/commons/Button';
/**
* Tells the server to set a relaxed pose to the current pose's angles.
@@ -33,7 +33,7 @@ export function DetectRelaxedPoseButton({
}
}}
>
{l10n.getString('settings-stay_aligned-relaxed_poses-detect_pose')}
{l10n.getString('settings-stay_aligned-relaxed_poses-save_pose')}
</Button>
);
}
@@ -43,9 +43,11 @@ export function DetectRelaxedPoseButton({
*/
export function ResetRelaxedPoseButton({
pose,
variant = 'primary',
onClick,
children,
}: {
variant: ButtonProps['variant'];
pose: StayAlignedRelaxedPose;
onClick?: MouseEventHandler<HTMLButtonElement>;
} & React.PropsWithChildren) {
@@ -54,7 +56,7 @@ export function ResetRelaxedPoseButton({
return (
<Button
variant="primary"
variant={variant}
onClick={(e) => {
const req = new DetectStayAlignedRelaxedPoseRequestT();
req.pose = pose;

View File

@@ -1,11 +1,6 @@
import { Canvas, Object3DNode, extend, useThree } from '@react-three/fiber';
import { Bone } from 'three';
import { useMemo, useEffect, useState } from 'react';
import {
OrbitControls,
OrthographicCamera,
PerspectiveCamera,
} from '@react-three/drei';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { useMemo, useEffect, useState, useRef, useLayoutEffect } from 'react';
import {
BoneKind,
createChildren,
@@ -21,53 +16,10 @@ import { ErrorBoundary } from 'react-error-boundary';
import { Typography } from '@/components/commons/Typography';
import { useAtomValue } from 'jotai';
import { bonesAtom } from '@/store/app-store';
extend({ BasedSkeletonHelper });
declare module '@react-three/fiber' {
interface ThreeElements {
basedSkeletonHelper: Object3DNode<
BasedSkeletonHelper,
typeof BasedSkeletonHelper
>;
}
}
import { useConfig } from '@/hooks/config';
import { Tween } from '@tweenjs/tween.js';
const GROUND_COLOR = '#4444aa';
const FRUSTUM_SIZE = 10;
const FACTOR = 2;
// Not currently used but nice to have
export function OrthographicCameraWrapper() {
const { size } = useThree();
const aspect = useMemo(() => size.width / size.height, [size]);
return (
<OrthographicCamera
makeDefault
zoom={200}
top={FRUSTUM_SIZE / FACTOR}
bottom={FRUSTUM_SIZE / -FACTOR}
left={(0.5 * FRUSTUM_SIZE * aspect) / -FACTOR}
right={(0.5 * FRUSTUM_SIZE * aspect) / FACTOR}
near={0.1}
far={1000}
position={[25, 75, 50]}
/>
);
}
export function SkeletonHelper({ object }: { object: Bone }) {
const { size } = useThree();
const res = useMemo(() => new THREE.Vector2(size.width, size.height), [size]);
return (
<basedSkeletonHelper
frustumCulled={false}
resolution={res}
args={[object]}
/>
);
}
// Just need to know the length of the total body, so don't need right legs
const Y_PARTS = [
@@ -135,45 +87,65 @@ export function ToggleableSkeletonVisualizerWidget({
);
}
export function SkeletonVisualizerWidget() {
const _bones = useAtomValue(bonesAtom);
export type SkeletonPreviewView = {
left: number;
bottom: number;
width: number;
height: number;
camera: THREE.PerspectiveCamera;
controls: OrbitControls;
hidden: boolean;
tween: Tween<THREE.Vector3>;
onHeightChange: (view: SkeletonPreviewView, newHeight: number) => void;
};
const { l10n } = useLocalization();
const bones = useMemo(() => {
return new Map(_bones.map((b) => [b.bodyPart, b]));
}, [_bones]);
function initializePreview(
canvas: HTMLCanvasElement,
skeleton: (BoneKind | THREE.Bone)[]
) {
let lastRenderTimeRef = 0;
let frameInterval = 0;
const skeleton = useMemo(
() => createChildren(bones, BoneKind.root),
[bones.size]
);
const views: SkeletonPreviewView[] = [];
useEffect(() => {
skeleton.forEach(
(bone) => bone instanceof BoneKind && bone.updateData(bones)
);
}, [bones]);
const resolution = new THREE.Vector2(canvas.clientWidth, canvas.clientHeight);
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
});
renderer.setSize(canvas.clientWidth, canvas.clientHeight);
const heightOffset = useMemo(() => {
const hmd = bones.get(BodyPart.HEAD);
// If I know the head position, don't use an offset
if (hmd?.headPositionG?.y !== undefined && hmd.headPositionG?.y > 0) {
return 0;
}
const yLength = Y_PARTS.map((x) => bones.get(x));
if (yLength.some((x) => x === undefined)) return 0;
return (yLength as BoneT[]).reduce((prev, cur) => prev + cur.boneLength, 0);
}, [bones]);
const grid = new THREE.GridHelper(10, 50, GROUND_COLOR, GROUND_COLOR);
grid.position.set(0, 0, 0);
scene.add(grid);
const targetCamera = useMemo(() => {
const hmd = bones.get(BodyPart.HEAD);
if (hmd?.headPositionG?.y && hmd.headPositionG.y > 0) {
return hmd.headPositionG.y / 2;
}
return heightOffset / 2;
}, [bones]);
const skeletonGroup = new THREE.Group();
let skeletonHelper = new BasedSkeletonHelper(skeleton[0]);
skeletonHelper.resolution.copy(resolution);
skeletonGroup.add(skeletonHelper);
scene.add(skeletonGroup);
scene.add(skeleton[0]);
let heightOffset = 0;
const rebuildSkeleton = (
newSkeleton: (BoneKind | THREE.Bone)[],
bones: Map<BodyPart, BoneT>
) => {
skeletonGroup.remove(skeletonHelper);
skeletonHelper.dispose();
scene.remove(skeleton[0]);
skeleton = newSkeleton;
skeletonHelper = new BasedSkeletonHelper(newSkeleton[0]);
skeletonHelper.resolution.copy(resolution);
skeletonGroup.add(skeletonHelper);
scene.add(newSkeleton[0]);
const yawReset = useMemo(() => {
const hmd = bones.get(BodyPart.HEAD);
const chest = bones.get(BodyPart.UPPER_CHEST);
// Check if HMD is identity, if it's then use upper chest's rotation
@@ -186,15 +158,277 @@ export function SkeletonVisualizerWidget() {
const vec = VEC_Y.multiplyScalar(
new THREE.Vector3(quat.x, quat.y, quat.z).dot(VEC_Y) / VEC_Y.lengthSq()
);
return new THREE.Quaternion(vec.x, vec.y, vec.z, quat.w).normalize();
const yawReset = new THREE.Quaternion(
vec.x,
vec.y,
vec.z,
quat.w
).normalize();
skeletonGroup.rotation.setFromQuaternion(yawReset);
};
const computeHeight = (bones: Map<BodyPart, BoneT>) => {
const hmd = bones.get(BodyPart.HEAD);
// If I know the head position, don't use an offset
if (hmd?.headPositionG?.y !== undefined && hmd.headPositionG?.y > 0) {
return 0;
}
const yLength = Y_PARTS.map((x) => bones.get(x));
if (yLength.some((x) => x === undefined)) return 0;
return (yLength as BoneT[]).reduce((prev, cur) => prev + cur.boneLength, 0);
};
const render = (delta: number) => {
views.forEach((v) => {
v.controls.update(delta);
const left = Math.floor(resolution.x * v.left);
const bottom = Math.floor(resolution.y * v.bottom);
const width = Math.floor(resolution.x * v.width);
const height = Math.floor(resolution.y * v.height);
renderer.setViewport(left, bottom, width, height);
renderer.setScissor(left, bottom, width, height);
renderer.setScissorTest(true);
v.tween.update();
v.camera.aspect = width / height;
v.camera.updateProjectionMatrix();
if (v.hidden) return;
renderer.render(scene, v.camera);
});
};
let animationFrameId: number;
const animate = (currentTime: number) => {
animationFrameId = requestAnimationFrame(animate);
if (currentTime - lastRenderTimeRef > frameInterval) {
lastRenderTimeRef = currentTime;
render(currentTime);
}
};
animationFrameId = requestAnimationFrame(animate);
// Make sure orbit controls works only on the current view
canvas.addEventListener('pointermove', (event) => {
const x = event.offsetX / resolution.x;
const y = 1 - event.offsetY / resolution.y;
views.forEach((v) => {
if (
x >= v.left &&
x <= v.left + v.width &&
y >= v.bottom &&
y <= v.bottom + v.height
) {
v.controls.enabled = true;
} else {
v.controls.enabled = false;
}
});
});
return {
resize: (width: number, height: number) => {
resolution.set(width, height);
skeletonHelper.resolution.copy(resolution);
renderer.setSize(width, height);
},
setFrameInterval: (interval: number) => {
frameInterval = interval;
},
rebuildSkeleton,
updatesBones: (bones: Map<BodyPart, BoneT>) => {
skeleton.forEach(
(bone) => bone instanceof BoneKind && bone.updateData(bones)
);
const newHeight = computeHeight(bones);
if (newHeight !== heightOffset) {
heightOffset = newHeight;
skeletonGroup.position.set(0, heightOffset, 0);
views.forEach((v) => {
v.onHeightChange(v, heightOffset);
});
}
},
destroy: () => {
skeletonHelper.dispose();
renderer.dispose();
cancelAnimationFrame(animationFrameId);
},
addView: ({
left,
bottom,
width,
height,
position,
hidden = false,
onHeightChange,
}: {
left: number;
bottom: number;
width: number;
height: number;
position: THREE.Vector3;
hidden?: boolean;
onHeightChange: (view: SkeletonPreviewView, newHeight: number) => void;
}) => {
const camera = new THREE.PerspectiveCamera(
20,
resolution.width / resolution.height,
0.1,
1000
);
const controls = new OrbitControls(camera, renderer.domElement);
controls.maxDistance = 20;
controls.dampingFactor = 0.2;
controls.enableDamping = true;
const tween = new Tween(position)
.onUpdate(() => {
camera.position.copy(position);
})
.onStart(() => (frameInterval = 0))
.onComplete(() => (frameInterval = 1000 / 30));
camera.position.copy(position);
const view: SkeletonPreviewView = {
camera,
left,
bottom,
width,
height,
controls,
tween,
hidden,
onHeightChange,
};
views.push(view);
return view;
},
};
}
const BASE_FRAMERATE = 60;
const LOW_FRAMERATE = 60;
type PreviewContext = ReturnType<typeof initializePreview>;
function SkeletonVisualizer({
onInit,
}: {
onInit: (context: PreviewContext) => void;
}) {
const { config } = useConfig();
const previewContext = useRef<PreviewContext | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resizeObserver = useRef(new ResizeObserver(([e]) => onResize(e)));
const _bones = useAtomValue(bonesAtom);
const bones = useMemo(() => {
return new Map(_bones.map((b) => [b.bodyPart, b]));
}, [_bones]);
useEffect(() => {
if (bones.size === 0) return;
const context = previewContext.current;
if (!context) return;
context.rebuildSkeleton(createChildren(bones, BoneKind.root), bones);
}, [bones.size]);
const scale = useMemo(
() => Math.max(1.8, heightOffset) / 1.8,
[heightOffset]
);
useEffect(() => {
const context = previewContext.current;
if (!context) return;
context.updatesBones(bones);
}, [bones]);
const onResize = (e: ResizeObserverEntry) => {
const context = previewContext.current;
if (!context || !containerRef.current || !canvasRef.current) return;
context.resize(e.contentRect.width, e.contentRect.height);
};
const onEnter = () => {
if (config?.devSettings.fastDataFeed) return;
const context = previewContext.current;
if (!context) return;
context.setFrameInterval(1000 / BASE_FRAMERATE);
};
const onLeave = () => {
if (config?.devSettings.fastDataFeed) return;
const context = previewContext.current;
if (!context) return;
context.setFrameInterval(1000 / LOW_FRAMERATE);
};
useLayoutEffect(() => {
if (!canvasRef.current || !containerRef.current)
throw 'invalid state - no canvas or container';
resizeObserver.current.observe(containerRef.current);
previewContext.current = initializePreview(
canvasRef.current,
createChildren(bones, BoneKind.root)
);
if (!config?.devSettings.fastDataFeed)
previewContext.current.setFrameInterval(1000 / LOW_FRAMERATE);
const rect = containerRef.current.getBoundingClientRect();
previewContext.current.resize(rect.width, rect.height);
containerRef.current.addEventListener('mouseenter', onEnter);
containerRef.current.addEventListener('mouseleave', onLeave);
onInit(previewContext.current);
return () => {
if (!previewContext.current || !containerRef.current) return;
resizeObserver.current.unobserve(containerRef.current);
previewContext.current.destroy();
containerRef.current.removeEventListener('mouseenter', onEnter);
containerRef.current.removeEventListener('mouseleave', onLeave);
};
}, []);
return (
<div ref={containerRef} className={classNames('w-full h-full')}>
<canvas ref={canvasRef} className="w-full h-full"></canvas>
</div>
);
}
export function SkeletonVisualizerWidget({
onInit = (context) => {
context.addView({
left: 0,
bottom: 0,
width: 1,
height: 1,
position: new THREE.Vector3(3, 2.5, -3),
onHeightChange(v, newHeight) {
v.controls.target.set(0, newHeight / 2, 0);
const scale = Math.max(1.8, newHeight) / 1.8;
v.camera.zoom = 1 / scale;
},
});
},
}: {
onInit?: (context: PreviewContext) => void;
}) {
const { l10n } = useLocalization();
if (!skeleton) return <></>;
return (
<ErrorBoundary
fallback={
@@ -203,24 +437,7 @@ export function SkeletonVisualizerWidget() {
</Typography>
}
>
<Canvas className={classNames('container mx-auto')}>
<gridHelper args={[10, 50, GROUND_COLOR, GROUND_COLOR]} />
<group position={[0, heightOffset, 0]} quaternion={yawReset}>
<SkeletonHelper object={skeleton[0]}></SkeletonHelper>
</group>
<primitive object={skeleton[0]} />
<PerspectiveCamera
makeDefault
position={[3, 2.5, -3]}
fov={20}
zoom={1 / scale}
/>
<OrbitControls
target={[0, targetCamera, 0]}
maxDistance={20}
maxPolarAngle={Math.PI / 2}
/>
</Canvas>
<SkeletonVisualizer onInit={onInit}></SkeletonVisualizer>
</ErrorBoundary>
);
}

View File

@@ -10,12 +10,12 @@ import {
} from 'solarxr-protocol';
import { playSoundOnResetEnded, playSoundOnResetStarted } from '@/sounds/sounds';
import { useConfig } from './config';
import { useDataFeedConfig } from './datafeed-config';
import { useBonesDataFeedConfig, useDataFeedConfig } from './datafeed-config';
import { useWebsocketAPI } from './websocket-api';
import { error } from '@/utils/logging';
import { cacheWrap } from './cache';
import { useAtomValue, useSetAtom } from 'jotai';
import { datafeedAtom, devicesAtom } from '@/store/app-store';
import { bonesAtom, datafeedAtom, devicesAtom } from '@/store/app-store';
import { updateSentryContext } from '@/utils/sentry';
export interface FirmwareRelease {
@@ -34,7 +34,9 @@ export function useProvideAppContext(): AppContext {
useWebsocketAPI();
const { config } = useConfig();
const { dataFeedConfig } = useDataFeedConfig();
const bonesDataFeedConfig = useBonesDataFeedConfig();
const setDatafeed = useSetAtom(datafeedAtom);
const setBones = useSetAtom(bonesAtom);
const devices = useAtomValue(devicesAtom);
const [currentFirmwareRelease, setCurrentFirmwareRelease] =
@@ -43,13 +45,17 @@ export function useProvideAppContext(): AppContext {
useEffect(() => {
if (isConnected) {
const startDataFeed = new StartDataFeedT();
startDataFeed.dataFeeds = [dataFeedConfig];
startDataFeed.dataFeeds = [dataFeedConfig, bonesDataFeedConfig];
sendDataFeedPacket(DataFeedMessage.StartDataFeed, startDataFeed);
}
}, [isConnected]);
useDataFeedPacket(DataFeedMessage.DataFeedUpdate, (packet: DataFeedUpdateT) => {
setDatafeed(packet);
if (packet.index === 0) {
setDatafeed(packet);
} else if (packet.index === 1) {
setBones(packet.bones);
}
});
useEffect(() => {

View File

@@ -26,7 +26,7 @@ export function useDataFeedConfig() {
const dataFeedConfig = new DataFeedConfigT();
dataFeedConfig.dataMask = dataMask;
dataFeedConfig.boneMask = true;
dataFeedConfig.boneMask = false;
dataFeedConfig.minimumTimeSinceLast = 1000 / feedMaxTps;
dataFeedConfig.syntheticTrackersMask = trackerData;
dataFeedConfig.stayAlignedPoseMask = true;
@@ -36,3 +36,10 @@ export function useDataFeedConfig() {
feedMaxTps,
};
}
export function useBonesDataFeedConfig() {
const dataFeedConfig = new DataFeedConfigT();
dataFeedConfig.boneMask = true;
dataFeedConfig.minimumTimeSinceLast = 1000 / 40;
return dataFeedConfig;
}

View File

@@ -1,6 +1,7 @@
import { atom } from 'jotai';
import {
BodyPart,
BoneT,
DataFeedUpdateT,
DeviceDataT,
TrackerDataT,
@@ -18,6 +19,8 @@ export const ignoredTrackersAtom = atom(new Set<string>());
export const datafeedAtom = atom(new DataFeedUpdateT());
export const bonesAtom = atom<BoneT[]>([]);
export const devicesAtom = selectAtom(
datafeedAtom,
(datafeed) => datafeed.devices,
@@ -56,12 +59,6 @@ export const computedTrackersAtom = selectAtom(
isEqual
);
export const bonesAtom = selectAtom(
datafeedAtom,
(datafeed) => datafeed.bones,
isEqual
);
export const hasHMDTrackerAtom = atom((get) => {
const trackers = get(flatTrackersAtom);

9
pnpm-lock.yaml generated
View File

@@ -71,6 +71,9 @@ importers:
'@tauri-apps/plugin-store':
specifier: ^2.0.0
version: 2.0.0
'@tweenjs/tween.js':
specifier: ^25.0.0
version: 25.0.0
'@twemoji/svg':
specifier: ^15.0.0
version: 15.0.0
@@ -831,6 +834,7 @@ packages:
'@react-hookz/deep-equal@3.0.3':
resolution: {integrity: sha512-SLy+NmiDpncqc2d9TR4Y4R7f8lUFOQK9WbnIq02A6wDxy+dTHfA2Np0dPvj0SFp6i1nqERLmEUe9MxPLuO/IqA==}
engines: {node: '>=18.0.0'}
deprecated: Package is deprecated and will be deleted soon. Use @ver0/deep-equal instead.
'@react-spring/animated@9.6.1':
resolution: {integrity: sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==}
@@ -1258,6 +1262,9 @@ packages:
'@tweenjs/tween.js@23.1.2':
resolution: {integrity: sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ==}
'@tweenjs/tween.js@25.0.0':
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
'@twemoji/svg@15.0.0':
resolution: {integrity: sha512-ZSPef2B6nBaYnfgdTbAy4jgW95o7pi2xPGwGCU+WMTxo7J6B1lMPTWwSq/wTuiMq+N0khQ90CcvYp1wFoQpo/w==}
@@ -5463,6 +5470,8 @@ snapshots:
'@tweenjs/tween.js@23.1.2': {}
'@tweenjs/tween.js@25.0.0': {}
'@twemoji/svg@15.0.0': {}
'@types/babel__core@7.20.5':

View File

@@ -54,7 +54,7 @@ public class DataFeedHandler extends ProtocolHandler<DataFeedMessageHeader> {
FlatBufferBuilder fbb = new FlatBufferBuilder(300);
int messageOffset = this.buildDatafeed(fbb, req.config().unpack());
int messageOffset = this.buildDatafeed(fbb, req.config().unpack(), 0);
DataFeedMessageHeader.startDataFeedMessageHeader(fbb);
DataFeedMessageHeader.addMessage(fbb, messageOffset);
@@ -70,7 +70,7 @@ public class DataFeedHandler extends ProtocolHandler<DataFeedMessageHeader> {
conn.send(fbb.dataBuffer());
}
public int buildDatafeed(FlatBufferBuilder fbb, DataFeedConfigT config) {
public int buildDatafeed(FlatBufferBuilder fbb, DataFeedConfigT config, int index) {
int devicesOffset = DataFeedBuilder
.createDevicesData(
fbb,
@@ -110,7 +110,8 @@ public class DataFeedHandler extends ProtocolHandler<DataFeedMessageHeader> {
devicesOffset,
trackersOffset,
bonesOffset,
stayAlignedPoseOffset
stayAlignedPoseOffset,
index
);
}
@@ -133,7 +134,7 @@ public class DataFeedHandler extends ProtocolHandler<DataFeedMessageHeader> {
fbb = new FlatBufferBuilder(300);
}
int messageOffset = this.buildDatafeed(fbb, configT);
int messageOffset = this.buildDatafeed(fbb, configT, index);
DataFeedMessageHeader.startDataFeedMessageHeader(fbb);
DataFeedMessageHeader.addMessage(fbb, messageOffset);

View File

@@ -131,7 +131,7 @@ class TrackerResetsHandler(val tracker: Tracker) {
* Reads/loads drift compensation settings from given config
*/
fun readDriftCompensationConfig(config: DriftCompensationConfig) {
compensateDrift = config.enabled
compensateDrift = false
driftPrediction = config.prediction
driftAmount = config.amount
val maxResets = config.maxResets