Add a ratio mode for manual offsets (#615)
6
Cargo.lock
generated
@@ -3133,16 +3133,16 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.25.0"
|
||||
version = "1.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8e00990ebabbe4c14c08aca901caed183ecd5c09562a12c824bb53d3c3fd3af"
|
||||
checksum = "03201d01c3c27a29c8a5cee5b55a93ddae1ccf6f08f65365c2c918f8c1b76f64"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"bytes",
|
||||
"memchr",
|
||||
"num_cpus",
|
||||
"pin-project-lite",
|
||||
"windows-sys 0.42.0",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
8
flake.lock
generated
@@ -32,16 +32,16 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1675410244,
|
||||
"narHash": "sha256-ODj6egMoH/HgAF/0wIy0EfRBeUx5FMuLl6uAdUW3kCI=",
|
||||
"lastModified": 1677407201,
|
||||
"narHash": "sha256-3blwdI9o1BAprkvlByHvtEm5HAIRn/XPjtcfiunpY7s=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f7543a7539a007e9562e4d8d24e17a4bcf369b68",
|
||||
"rev": "7f5639fa3b68054ca0b062866dc62b22c3f11505",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-22.11",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
description = "Server app for SlimeVR ecosystem";
|
||||
|
||||
inputs.nixpkgs.url = "nixpkgs/nixos-22.11";
|
||||
inputs.nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||
|
||||
inputs.rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
@@ -57,7 +57,7 @@
|
||||
exa
|
||||
fd
|
||||
|
||||
jdk # JDK17
|
||||
jdk17 # JDK17
|
||||
nodejs
|
||||
gradle
|
||||
];
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"postcss": "^8.4.12",
|
||||
"prettier": "^2.7.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"tailwind-gradient-mask-image": "^1.0.0",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"vite": "^4.0.3"
|
||||
}
|
||||
|
||||
@@ -40,12 +40,14 @@ body_part-LEFT_FOOT = Left foot
|
||||
skeleton_bone-NONE = None
|
||||
skeleton_bone-HEAD = Head Shift
|
||||
skeleton_bone-NECK = Neck Length
|
||||
skeleton_bone-torso_group = Torso length
|
||||
skeleton_bone-CHEST = Chest Length
|
||||
skeleton_bone-CHEST_OFFSET = Chest Offset
|
||||
skeleton_bone-WAIST = Waist Length
|
||||
skeleton_bone-HIP = Hip Length
|
||||
skeleton_bone-HIP_OFFSET = Hip Offset
|
||||
skeleton_bone-HIPS_WIDTH = Hips Width
|
||||
skeleton_bone-leg_group = Leg length
|
||||
skeleton_bone-UPPER_LEG = Upper Leg Length
|
||||
skeleton_bone-LOWER_LEG = Lower Leg Length
|
||||
skeleton_bone-FOOT_LENGTH = Foot Length
|
||||
@@ -53,6 +55,7 @@ skeleton_bone-FOOT_SHIFT = Foot Shift
|
||||
skeleton_bone-SKELETON_OFFSET = Skeleton Offset
|
||||
skeleton_bone-SHOULDERS_DISTANCE = Shoulders Distance
|
||||
skeleton_bone-SHOULDERS_WIDTH = Shoulders Width
|
||||
skeleton_bone-arm_group = Arm length
|
||||
skeleton_bone-UPPER_ARM = Upper Arm Length
|
||||
skeleton_bone-LOWER_ARM = Lower Arm Length
|
||||
skeleton_bone-HAND_Y = Hand Distance Y
|
||||
@@ -615,6 +618,7 @@ onboarding-manual_proportions-back = Go Back to Reset tutorial
|
||||
onboarding-manual_proportions-title = Manual Body Proportions
|
||||
onboarding-manual_proportions-precision = Precision adjust
|
||||
onboarding-manual_proportions-auto = Automatic calibration
|
||||
onboarding-manual_proportions-ratio = Adjust by ratio groups
|
||||
|
||||
## Tracker automatic proportions setup
|
||||
onboarding-automatic_proportions-back = Go Back to Reset tutorial
|
||||
|
||||
BIN
gui/src-tauri/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
gui/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
gui/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
gui/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
gui/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
gui/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
gui/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
gui/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
gui/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
gui/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
gui/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
gui/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
gui/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
gui/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
gui/src-tauri/icons/icon.icns
Normal file
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 23 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" stroke-miterlimit="10" clip-rule="evenodd" version="1.1" viewBox="0 0 380 380" xml:space="preserve"><rect id="bg" width="380" height="380" fill="#663499" stroke-width="1"/><g id="logo" fill="none" stroke="#fff"><path id="left" stroke-width="13.62" d="m72.867 191.74 37-39 39 36"/><path id="right" stroke-width="13.62" d="m208.87 187.74 38-35 36 38"/><path id="outer" stroke-linecap="square" stroke-width="17" d="m56.867 253.74s130.61-31.182 248 5c13.45 4.146 20.244 2.975 20-8s1.909-126.06-46-131"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" stroke-miterlimit="10" clip-rule="evenodd" version="1.1" viewBox="0 0 380 380" xml:space="preserve"><rect id="bg" width="380" height="380" fill="#663499" stroke-width="1"/><g id="logo" fill="none" stroke="#fff"><path id="left" stroke-width="13.62" d="m72.867 191.74 37-39 39 36"/><path id="right" stroke-width="13.62" d="m208.87 187.74 38-35 36 38"/><path id="outer" stroke-linecap="square" stroke-width="17" d="m56.867 253.74s130.61-31.182 248 5c13.45 4.146 20.244 2.975 20-8s1.909-126.06-46-131"/></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 569 B After Width: | Height: | Size: 570 B |
@@ -13,7 +13,9 @@ use const_format::concatcp;
|
||||
use rand::{seq::SliceRandom, thread_rng};
|
||||
use shadow_rs::shadow;
|
||||
use tauri::api::process::Command;
|
||||
use tauri::{Manager, WindowEvent};
|
||||
use tauri::Manager;
|
||||
#[cfg(windows)]
|
||||
use tauri::WindowEvent;
|
||||
use tempfile::Builder;
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"identifier": "dev.slimevr.SlimeVR",
|
||||
"icon": ["icons/icon.ico", "icons/icon.png"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [],
|
||||
"externalBin": [],
|
||||
"copyright": "",
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import classNames from 'classnames';
|
||||
import { MouseEventHandler, ReactNode, useEffect } from 'react';
|
||||
import {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
ChangeSkeletonConfigRequestT,
|
||||
RpcMessage,
|
||||
SkeletonBone,
|
||||
SkeletonConfigRequestT,
|
||||
SkeletonConfigResponseT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from '../../../../hooks/websocket-api';
|
||||
LabelType,
|
||||
ProportionChangeType,
|
||||
useManualProportions,
|
||||
} from '../../../../hooks/manual-proportions';
|
||||
import { useLocaleConfig } from '../../../../i18n/config';
|
||||
import { Typography } from '../../../commons/Typography';
|
||||
|
||||
function IncrementButton({
|
||||
@@ -40,156 +32,239 @@ function IncrementButton({
|
||||
|
||||
export function BodyProportions({
|
||||
precise,
|
||||
variant = 'onboarding',
|
||||
type,
|
||||
variant: _variant = 'onboarding',
|
||||
}: {
|
||||
precise: boolean;
|
||||
type: 'linear' | 'ratio';
|
||||
variant: 'onboarding' | 'alone';
|
||||
}) {
|
||||
const [bodyParts, _ratioMode, currentSelection, dispatch, setRatioMode] =
|
||||
useManualProportions();
|
||||
const { currentLocales } = useLocaleConfig();
|
||||
const { l10n } = useLocalization();
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [config, setConfig] = useState<Omit<
|
||||
SkeletonConfigResponseT,
|
||||
'pack'
|
||||
> | null>(null);
|
||||
const [selectedBone, setSelectedBone] = useState(SkeletonBone.HEAD);
|
||||
const bodyParts = useMemo(() => {
|
||||
return (
|
||||
config?.skeletonParts.map(({ bone, value }) => ({
|
||||
bone,
|
||||
label: l10n.getString('skeleton_bone-' + SkeletonBone[bone]),
|
||||
value,
|
||||
})) || []
|
||||
);
|
||||
}, [config]);
|
||||
|
||||
useRPCPacket(
|
||||
RpcMessage.SkeletonConfigResponse,
|
||||
(data: SkeletonConfigResponseT) => {
|
||||
setConfig(data);
|
||||
}
|
||||
);
|
||||
const cmFormat = Intl.NumberFormat(currentLocales, {
|
||||
style: 'unit',
|
||||
unit: 'centimeter',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const configFormat = Intl.NumberFormat(currentLocales, {
|
||||
signDisplay: 'always',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const percentageFormat = Intl.NumberFormat(currentLocales, {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
sendRPCPacket(
|
||||
RpcMessage.SkeletonConfigRequest,
|
||||
new SkeletonConfigRequestT()
|
||||
);
|
||||
}, []);
|
||||
|
||||
const roundedStep = (value: number, step: number, add: boolean) => {
|
||||
if (!add) {
|
||||
return (Math.round(value * 200) - step * 2) / 200;
|
||||
if (type === 'linear') {
|
||||
setRatioMode(false);
|
||||
} else {
|
||||
return (Math.round(value * 200) + step * 2) / 200;
|
||||
setRatioMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfigValue = (configChange: ChangeSkeletonConfigRequestT) => {
|
||||
sendRPCPacket(RpcMessage.ChangeSkeletonConfigRequest, configChange);
|
||||
const conf = { ...config } as Omit<SkeletonConfigResponseT, 'pack'> | null;
|
||||
const b = conf?.skeletonParts?.find(({ bone }) => bone == selectedBone);
|
||||
if (!b || !conf) return;
|
||||
b.value = configChange.value;
|
||||
setConfig(conf);
|
||||
};
|
||||
|
||||
const increment = async (value: number, v: number) => {
|
||||
const configChange = new ChangeSkeletonConfigRequestT();
|
||||
|
||||
configChange.bone = selectedBone;
|
||||
configChange.value = roundedStep(value, v, true);
|
||||
|
||||
updateConfigValue(configChange);
|
||||
};
|
||||
|
||||
const decrement = (value: number, v: number) => {
|
||||
const configChange = new ChangeSkeletonConfigRequestT();
|
||||
|
||||
configChange.bone = selectedBone;
|
||||
configChange.value = value - v / 100;
|
||||
|
||||
updateConfigValue(configChange);
|
||||
};
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="flex flex-col overflow-y-scroll overflow-x-hidden max-h-[450px] w-full px-1 gap-3 pb-16">
|
||||
{bodyParts.map(({ label, bone, value }) => (
|
||||
<div className="flex" key={bone}>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex gap-2 transition-opacity duration-300',
|
||||
selectedBone != bone && 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{!precise && (
|
||||
<IncrementButton onClick={() => decrement(value, 5)}>
|
||||
-5
|
||||
</IncrementButton>
|
||||
)}
|
||||
<IncrementButton onClick={() => decrement(value, 1)}>
|
||||
-1
|
||||
</IncrementButton>
|
||||
{precise && (
|
||||
<IncrementButton onClick={() => decrement(value, 0.5)}>
|
||||
-0.5
|
||||
</IncrementButton>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-grow flex-col px-2"
|
||||
onClick={() => setSelectedBone(bone)}
|
||||
>
|
||||
<div
|
||||
key={bone}
|
||||
className={classNames(
|
||||
'p-3 rounded-lg h-16 flex w-full items-center justify-between px-6 transition-colors duration-300 bg-background-60',
|
||||
(selectedBone == bone && 'opacity-100') || 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Typography variant="section-title" bold>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="main-title" bold>
|
||||
{Number(value * 100)
|
||||
.toFixed(1)
|
||||
.replace(/[.,]0$/, '')}{' '}
|
||||
CM
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex gap-2 transition-opacity duration-300',
|
||||
selectedBone != bone && 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{precise && (
|
||||
<IncrementButton onClick={() => increment(value, 0.5)}>
|
||||
+0.5
|
||||
</IncrementButton>
|
||||
)}
|
||||
<IncrementButton onClick={() => increment(value, 1)}>
|
||||
+1
|
||||
</IncrementButton>
|
||||
{!precise && (
|
||||
<IncrementButton onClick={() => increment(value, 5)}>
|
||||
+5
|
||||
</IncrementButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col overflow-y-scroll overflow-x-hidden max-h-[450px] h-[54vh]',
|
||||
'w-full px-1 gap-3 gradient-mask-b-90'
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{bodyParts.map(({ label, type, value: originalValue, ...props }) => {
|
||||
const value =
|
||||
'index' in props && props.index !== undefined
|
||||
? props.bones[props.index].value
|
||||
: originalValue;
|
||||
const selected = currentSelection.label === label;
|
||||
|
||||
<div className="absolute bottom-0 h-20 w-full pointer-events-none">
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full h-full bg-gradient-to-b from-transparent opacity-100',
|
||||
variant === 'onboarding' && 'to-background-80',
|
||||
variant === 'alone' && 'to-background-70'
|
||||
)}
|
||||
></div>
|
||||
const selectNew = () => {
|
||||
switch (type) {
|
||||
case LabelType.Bone: {
|
||||
if (!('bone' in props)) throw 'unreachable';
|
||||
dispatch({
|
||||
...props,
|
||||
label,
|
||||
value,
|
||||
type: ProportionChangeType.Bone,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LabelType.Group: {
|
||||
if (!('bones' in props)) throw 'unreachable';
|
||||
dispatch({
|
||||
...props,
|
||||
label,
|
||||
value,
|
||||
type: ProportionChangeType.Group,
|
||||
index: undefined,
|
||||
parentLabel: label,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LabelType.GroupPart: {
|
||||
if (!('index' in props)) throw 'unreachable';
|
||||
dispatch({
|
||||
...props,
|
||||
label,
|
||||
// If this isn't done, we are replacing total
|
||||
// with percentage value
|
||||
value: originalValue,
|
||||
type: ProportionChangeType.Group,
|
||||
index: props.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex" key={label}>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex gap-2 transition-opacity duration-300',
|
||||
!selected && 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{!precise && (
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: -0.05,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: -5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(-5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: -0.01,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: -1,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(-1)}
|
||||
</IncrementButton>
|
||||
{precise && (
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: -0.005,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: -0.5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(-0.5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-grow flex-col px-2"
|
||||
onClick={selectNew}
|
||||
>
|
||||
<div
|
||||
key={label}
|
||||
className={classNames(
|
||||
'p-3 rounded-lg h-16 flex w-full items-center justify-between px-6 transition-colors duration-300 bg-background-60',
|
||||
(selected && 'opacity-100') || 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<Typography variant="section-title" bold>
|
||||
{l10n.getString(label)}
|
||||
</Typography>
|
||||
<Typography variant="main-title" bold>
|
||||
{type === LabelType.GroupPart
|
||||
? /* Make number rounding so it's based on .5 decimals */
|
||||
percentageFormat.format(Math.round(value * 200) / 200)
|
||||
: cmFormat.format(value * 100)}
|
||||
{type === LabelType.GroupPart && (
|
||||
<p className="text-standard">{`(${cmFormat.format(
|
||||
value * originalValue * 100
|
||||
)})`}</p>
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex gap-2 transition-opacity duration-300',
|
||||
!selected && 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
{precise && (
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: 0.005,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: 0.5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(+0.5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: 0.01,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(+1)}
|
||||
</IncrementButton>
|
||||
{!precise && (
|
||||
<IncrementButton
|
||||
onClick={() =>
|
||||
type === LabelType.GroupPart
|
||||
? dispatch({
|
||||
type: ProportionChangeType.Ratio,
|
||||
value: 0.05,
|
||||
})
|
||||
: dispatch({
|
||||
type: ProportionChangeType.Linear,
|
||||
value: 5,
|
||||
})
|
||||
}
|
||||
>
|
||||
{configFormat.format(+5)}
|
||||
</IncrementButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PersonFrontIcon } from '../../../commons/PersonFrontIcon';
|
||||
import { Typography } from '../../../commons/Typography';
|
||||
import { BodyProportions } from './BodyProportions';
|
||||
import { useLocalization } from '@fluent/react';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useBodyProportions } from '../../../../hooks/body-proportions';
|
||||
|
||||
export function ManualProportionsPage() {
|
||||
@@ -20,10 +20,12 @@ export function ManualProportionsPage() {
|
||||
|
||||
applyProgress(0.9);
|
||||
|
||||
const { control, watch } = useForm<{ precise: boolean }>({
|
||||
defaultValues: { precise: false },
|
||||
const savedValue = useMemo(() => localStorage.getItem('ratioMode'), []);
|
||||
|
||||
const { control, watch } = useForm<{ precise: boolean; ratio: boolean }>({
|
||||
defaultValues: { precise: false, ratio: savedValue !== 'false' },
|
||||
});
|
||||
const { precise } = watch();
|
||||
const { precise, ratio } = watch();
|
||||
|
||||
const resetAll = () => {
|
||||
sendRPCPacket(
|
||||
@@ -32,6 +34,10 @@ export function ManualProportionsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ratioMode', ratio.toString());
|
||||
}, [ratio]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageOpened();
|
||||
}, []);
|
||||
@@ -51,6 +57,12 @@ export function ManualProportionsPage() {
|
||||
<Typography variant="main-title">
|
||||
{l10n.getString('onboarding-manual_proportions-title')}
|
||||
</Typography>
|
||||
<CheckBox
|
||||
control={control}
|
||||
label={l10n.getString('onboarding-manual_proportions-ratio')}
|
||||
name="ratio"
|
||||
variant="toggle"
|
||||
></CheckBox>
|
||||
<CheckBox
|
||||
control={control}
|
||||
label={l10n.getString(
|
||||
@@ -62,6 +74,7 @@ export function ManualProportionsPage() {
|
||||
</div>
|
||||
<BodyProportions
|
||||
precise={precise}
|
||||
type={ratio ? 'ratio' : 'linear'}
|
||||
variant={state.alonePage ? 'alone' : 'onboarding'}
|
||||
></BodyProportions>
|
||||
</div>
|
||||
|
||||
357
gui/src/hooks/manual-proportions.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import {
|
||||
SkeletonConfigResponseT,
|
||||
SkeletonBone,
|
||||
RpcMessage,
|
||||
SkeletonConfigRequestT,
|
||||
ChangeSkeletonConfigRequestT,
|
||||
} from 'solarxr-protocol';
|
||||
import { useWebsocketAPI } from './websocket-api';
|
||||
import { useReducer, useEffect, useMemo, useState, useLayoutEffect } from 'react';
|
||||
|
||||
export type ProportionChange = LinearChange | RatioChange | BoneChange | GroupChange;
|
||||
|
||||
export enum ProportionChangeType {
|
||||
Linear,
|
||||
Ratio,
|
||||
Bone,
|
||||
Group,
|
||||
}
|
||||
|
||||
export interface LinearChange {
|
||||
type: ProportionChangeType.Linear;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface RatioChange {
|
||||
type: ProportionChangeType.Ratio;
|
||||
/**
|
||||
* This is a number between -1 and 1 [-1; 1]
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BoneChange {
|
||||
type: ProportionChangeType.Bone;
|
||||
bone: SkeletonBone;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupChange {
|
||||
type: ProportionChangeType.Group;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
index?: number;
|
||||
parentLabel: string;
|
||||
}
|
||||
|
||||
export type ProportionState = BoneState | GroupState;
|
||||
|
||||
export enum BoneType {
|
||||
Single,
|
||||
Group,
|
||||
}
|
||||
|
||||
export interface BoneState {
|
||||
type: BoneType.Single;
|
||||
bone: SkeletonBone;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupState {
|
||||
type: BoneType.Group;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
index?: number;
|
||||
parentLabel: string;
|
||||
}
|
||||
|
||||
function reducer(state: ProportionState, action: ProportionChange): ProportionState {
|
||||
switch (action.type) {
|
||||
case ProportionChangeType.Bone: {
|
||||
return {
|
||||
...action,
|
||||
type: BoneType.Single,
|
||||
};
|
||||
}
|
||||
|
||||
case ProportionChangeType.Group: {
|
||||
return {
|
||||
...action,
|
||||
type: BoneType.Group,
|
||||
};
|
||||
}
|
||||
|
||||
case ProportionChangeType.Linear: {
|
||||
if (action.value > 0) {
|
||||
return {
|
||||
...state,
|
||||
value: roundedStep(state.value, action.value, true),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
value: state.value + action.value / 100,
|
||||
};
|
||||
}
|
||||
|
||||
case ProportionChangeType.Ratio: {
|
||||
if (state.type === BoneType.Single || state.index === undefined) {
|
||||
throw new Error(`Unexpected increase of bone ${state.label}`);
|
||||
}
|
||||
|
||||
const newState: GroupState = JSON.parse(JSON.stringify(state));
|
||||
if (newState.index === undefined) throw 'unreachable';
|
||||
newState.bones[newState.index].value += action.value;
|
||||
if (newState.bones[newState.index].value <= 0) return state;
|
||||
const filtered = newState.bones.filter((_it, index) => newState.index !== index);
|
||||
const total = filtered.reduce((acc, cur) => acc + cur.value, 0);
|
||||
|
||||
for (const part of filtered) {
|
||||
part.value += (part.value / total) * action.value * -1;
|
||||
if (part.value <= 0) return state;
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Label = BoneLabel | GroupLabel | GroupPartLabel;
|
||||
|
||||
export enum LabelType {
|
||||
Bone,
|
||||
Group,
|
||||
GroupPart,
|
||||
}
|
||||
|
||||
export interface BoneLabel {
|
||||
type: LabelType.Bone;
|
||||
bone: SkeletonBone;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupLabel {
|
||||
type: LabelType.Group;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GroupPartLabel {
|
||||
type: LabelType.GroupPart;
|
||||
bones: {
|
||||
bone: SkeletonBone;
|
||||
/**
|
||||
* This is a number between 0 and 1 [0; 1]
|
||||
*/
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
value: number;
|
||||
label: string;
|
||||
parentLabel: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const BONE_MAPPING: Map<string, SkeletonBone[]> = new Map([
|
||||
[
|
||||
'skeleton_bone-torso_group',
|
||||
[SkeletonBone.CHEST, SkeletonBone.HIP, SkeletonBone.WAIST],
|
||||
],
|
||||
['skeleton_bone-leg_group', [SkeletonBone.UPPER_LEG, SkeletonBone.LOWER_LEG]],
|
||||
['skeleton_bone-arm_group', [SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM]],
|
||||
]);
|
||||
|
||||
export const INVALID_BONE: BoneState = {
|
||||
type: BoneType.Single,
|
||||
bone: SkeletonBone.NONE,
|
||||
value: 0,
|
||||
label: 'invalid-bone',
|
||||
};
|
||||
|
||||
export function useManualProportions(): [
|
||||
Label[],
|
||||
boolean,
|
||||
ProportionState,
|
||||
(change: ProportionChange) => void,
|
||||
(ratio: boolean) => void
|
||||
] {
|
||||
const { useRPCPacket, sendRPCPacket } = useWebsocketAPI();
|
||||
const [config, setConfig] = useState<Omit<SkeletonConfigResponseT, 'pack'> | null>(
|
||||
null
|
||||
);
|
||||
const [ratio, setRatio] = useState(false);
|
||||
const [state, dispatch] = useReducer(reducer, INVALID_BONE);
|
||||
|
||||
const bodyParts: Label[] = useMemo(() => {
|
||||
if (!config) return [];
|
||||
if (ratio) {
|
||||
const groups: GroupPartLabel[] = [];
|
||||
for (const [label, related] of BONE_MAPPING) {
|
||||
const children = config.skeletonParts.filter((it) => related.includes(it.bone));
|
||||
const total = children.reduce((acc, cur) => cur.value + acc, 0);
|
||||
|
||||
const group: GroupPartLabel = {
|
||||
parentLabel: label,
|
||||
label,
|
||||
type: LabelType.GroupPart,
|
||||
value: total,
|
||||
bones: children.map((it) => ({
|
||||
label: 'skeleton_bone-' + SkeletonBone[it.bone],
|
||||
value: it.value / total,
|
||||
bone: it.bone,
|
||||
})),
|
||||
index: 0,
|
||||
};
|
||||
groups.push(
|
||||
...children.map((_it, index) => ({
|
||||
...group,
|
||||
index,
|
||||
label: group.bones[index].label,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return config.skeletonParts.flatMap(({ bone, value }) => {
|
||||
const part = groups.find((it) => it.bones[it.index].bone === bone);
|
||||
if (part === undefined) {
|
||||
return {
|
||||
type: LabelType.Bone,
|
||||
bone,
|
||||
label: 'skeleton_bone-' + SkeletonBone[bone],
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
if (part.index === 0) {
|
||||
return [
|
||||
// For some reason, Typescript can't handle this being a GroupPart
|
||||
// when specifically inside an array. If I directly return it without part,
|
||||
// it will work. Surely some typing in flatMap's definition is wrong
|
||||
{
|
||||
...part,
|
||||
type: LabelType.Group,
|
||||
label: part.parentLabel,
|
||||
index: undefined,
|
||||
} as unknown as GroupPartLabel,
|
||||
part,
|
||||
];
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
return config.skeletonParts.map(({ bone, value }) => ({
|
||||
type: LabelType.Bone,
|
||||
bone,
|
||||
label: 'skeleton_bone-' + SkeletonBone[bone],
|
||||
value,
|
||||
}));
|
||||
}, [config, ratio]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
dispatch({
|
||||
...INVALID_BONE,
|
||||
type: ProportionChangeType.Bone,
|
||||
});
|
||||
}, [ratio]);
|
||||
|
||||
useRPCPacket(RpcMessage.SkeletonConfigResponse, (data: SkeletonConfigResponseT) => {
|
||||
setConfig(data);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
sendRPCPacket(RpcMessage.SkeletonConfigRequest, new SkeletonConfigRequestT());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const conf = { ...config } as Omit<SkeletonConfigResponseT, 'pack'> | null;
|
||||
|
||||
if (state.type === BoneType.Single) {
|
||||
// Just ignore if bone is none (because initial state value)
|
||||
// and check if we actually changed of value
|
||||
if (
|
||||
state.bone === SkeletonBone.NONE ||
|
||||
bodyParts.find((it) => it.type === LabelType.Bone && it.bone === state.bone)
|
||||
?.value === state.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(state.bone, state.value)
|
||||
);
|
||||
const b = conf?.skeletonParts?.find(({ bone }) => bone === state.bone);
|
||||
if (!b || !conf) return;
|
||||
b.value = state.value;
|
||||
} else {
|
||||
const part = bodyParts.find(
|
||||
(it) =>
|
||||
it.type === LabelType.Group &&
|
||||
(it.label === state.label || it.label === state.parentLabel)
|
||||
) as GroupLabel | undefined;
|
||||
|
||||
// Check if we found the group we were looking for
|
||||
// and check if it even changed of value
|
||||
// we only need to check one child because changing one
|
||||
// value propagates to the other children
|
||||
|
||||
if (
|
||||
!part ||
|
||||
(part.value === state.value && part.bones[0].value === state.bones[0].value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of state.bones) {
|
||||
sendRPCPacket(
|
||||
RpcMessage.ChangeSkeletonConfigRequest,
|
||||
new ChangeSkeletonConfigRequestT(child.bone, state.value * child.value)
|
||||
);
|
||||
|
||||
const b = conf?.skeletonParts?.find(({ bone }) => bone === child.bone);
|
||||
if (!b || !conf) return;
|
||||
b.value = state.value * child.value;
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(conf);
|
||||
}, [state]);
|
||||
|
||||
return [bodyParts, ratio, state, dispatch, setRatio];
|
||||
}
|
||||
|
||||
function roundedStep(value: number, step: number, add: boolean): number {
|
||||
if (!add) {
|
||||
return (Math.round(value * 200) - step * 2) / 200;
|
||||
} else {
|
||||
return (Math.round(value * 200) + step * 2) / 200;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('tailwind-gradient-mask-image'),
|
||||
plugin(function ({ addUtilities, theme }) {
|
||||
const textConfig = (fontSize, fontWeight) => ({
|
||||
fontSize,
|
||||
|
||||
14
package-lock.json
generated
@@ -70,6 +70,7 @@
|
||||
"postcss": "^8.4.12",
|
||||
"prettier": "^2.7.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"tailwind-gradient-mask-image": "^1.0.0",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"vite": "^4.0.3"
|
||||
}
|
||||
@@ -8874,6 +8875,12 @@
|
||||
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tailwind-gradient-mask-image": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-gradient-mask-image/-/tailwind-gradient-mask-image-1.0.0.tgz",
|
||||
"integrity": "sha512-eZJhn6wHZ0Irfpq5sm0ErewH6IC82gqjfVsDQ7MYrJcfTgepOoTI6EryLppNPoZds4EeD6/H0WrysT8HE90H5g==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz",
|
||||
@@ -15422,6 +15429,7 @@
|
||||
"react-modal": "3.15.1",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"solarxr-protocol": "file:../solarxr-protocol",
|
||||
"tailwind-gradient-mask-image": "^1.0.0",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"three": "^0.148.0",
|
||||
"typescript": "^4.6.3",
|
||||
@@ -15583,6 +15591,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"tailwind-gradient-mask-image": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-gradient-mask-image/-/tailwind-gradient-mask-image-1.0.0.tgz",
|
||||
"integrity": "sha512-eZJhn6wHZ0Irfpq5sm0ErewH6IC82gqjfVsDQ7MYrJcfTgepOoTI6EryLppNPoZds4EeD6/H0WrysT8HE90H5g==",
|
||||
"dev": true
|
||||
},
|
||||
"tailwindcss": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz",
|
||||
|
||||