diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 58be90a816..d39f459e0c 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -437,6 +437,17 @@ tracker_selection_menu-LEFT_HAND = { -tracker_selection-part } left hand? tracker_selection_menu-LEFT_UPPER_LEG = { -tracker_selection-part } left thigh? tracker_selection_menu-LEFT_LOWER_LEG = { -tracker_selection-part } left ankle? tracker_selection_menu-LEFT_FOOT = { -tracker_selection-part } left foot? +tracker_selection_menu-LEFT_BIG_TOE = { -tracker_selection-part } left big toe? +tracker_selection_menu-LEFT_INDEX_TOE = { -tracker_selection-part } left index toe? +tracker_selection_menu-LEFT_MIDDLE_TOE = { -tracker_selection-part } left middle toe? +tracker_selection_menu-LEFT_RING_TOE = { -tracker_selection-part } left ring toe? +tracker_selection_menu-LEFT_LITTLE_TOE = { -tracker_selection-part } left little toe? +tracker_selection_menu-RIGHT_BIG_TOE = { -tracker_selection-part } right big toe? +tracker_selection_menu-RIGHT_INDEX_TOE = { -tracker_selection-part } right index toe? +tracker_selection_menu-RIGHT_MIDDLE_TOE = { -tracker_selection-part } right middle toe? +tracker_selection_menu-RIGHT_RING_TOE = { -tracker_selection-part } right ring toe? +tracker_selection_menu-RIGHT_LITTLE_TOE = { -tracker_selection-part } right little toe? + tracker_selection_menu-unassigned = Unassigned Trackers tracker_selection_menu-assigned = Assigned Trackers @@ -1193,6 +1204,42 @@ onboarding-assign_trackers-warning-WAIST = Waist is assigned but you need { $una *[unknown] Unknown unassigned body part } to also be assigned! +onboarding-assign_trackers-warning-LEFT_BIG_TOE = Left Big Toe is assigned but you need { $unassigned -> + [0] the left foot + *[unknown] Unknown unassigned body part +} to also be assigned! +onboarding-assign_trackers-warning-LEFT_INDEX_TOE = Left Index Toe is assigned but you need { $unassigned -> + [0] the left foot + *[unknown] Unknown unassigned body part +} to also be assigned! +onboarding-assign_trackers-warning-LEFT_MIDDLE_TOE = Left Middle Toe is assigned but you need { $unassigned -> + [0] the left foot + *[unknown] Unknown unassigned body part} to also be assigned! +onboarding-assign_trackers-warning-LEFT_RING_TOE = Left Ring Toe is assigned but you need { $unassigned -> + [0] the left foot + *[unknown] Unknown unassigned body part} to also be assigned! +onboarding-assign_trackers-warning-LEFT_LITTLE_TOE = Left Little Toe is assigned but you need { $unassigned -> + [0] the left foot + *[unknown] Unknown unassigned body part} to also be assigned! +onboarding-assign_trackers-warning-RIGHT_BIG_TOE = Left Big Toe is assigned but you need { $unassigned -> + [0] the right foot + *[unknown] Unknown unassigned body part +} to also be assigned! +onboarding-assign_trackers-warning-RIGHT_INDEX_TOE = Right Index Toe is assigned but you need { $unassigned -> + [0] the right foot + *[unknown] Unknown unassigned body part +} to also be assigned! +onboarding-assign_trackers-warning-RIGHT_MIDDLE_TOE = Right Middle Toe is assigned but you need { $unassigned -> + [0] the right foot + *[unknown] Unknown unassigned body part} to also be assigned! +onboarding-assign_trackers-warning-RIGHT_RING_TOE = Right Ring Toe is assigned but you need { $unassigned -> + [0] the right foot + *[unknown] Unknown unassigned body part} to also be assigned! +onboarding-assign_trackers-warning-RIGHT_LITTLE_TOE = Right Little Toe is assigned but you need { $unassigned -> + [0] the right foot + *[unknown] Unknown unassigned body part} to also be assigned! + + ## Tracker mounting method choose onboarding-choose_mounting = What mounting calibration method to use? # Multiline text @@ -1704,3 +1751,15 @@ toolbar-mounting_calibration-fingers = Fingers toolbar-drift_reset = Drift Reset toolbar-assigned_trackers = {$count} trackers assigned toolbar-unassigned_trackers = {$count} trackers unassigned +body_part-LEFT_BIG_TOE = Left Big Toe +body_part-LEFT_INDEX_TOE = Left Index Toe +body_part-LEFT_MIDDLE_TOE = Left Middle Toe +body_part-LEFT_RING_TOE = Left Ring Toe +body_part-LEFT_LITTLE_TOE = Left Little Toe + +body_part-RIGHT_TOES = Right Toes +body_part-RIGHT_BIG_TOE = Right Big Toe +body_part-RIGHT_INDEX_TOE = Right Index Toe +body_part-RIGHT_MIDDLE_TOE = Right Middle Toe +body_part-RIGHT_RING_TOE = Right Ring Toe +body_part-RIGHT_LITTLE_TOE = Right Little Toe diff --git a/gui/app/src/components/Toolbar.tsx b/gui/app/src/components/Toolbar.tsx index 9a5c8813d0..dcae37cd2b 100644 --- a/gui/app/src/components/Toolbar.tsx +++ b/gui/app/src/components/Toolbar.tsx @@ -147,7 +147,7 @@ export function Toolbar() { const { visibleGroups, groupVisibility } = useMemo(() => { const groupVisibility = Object.keys(BODY_PARTS_GROUPS) - .filter((k) => ['fingers'].includes(k)) + .filter((k) => ['fingers', 'toes'].includes(k)) .reduce( (curr, key) => { const group = key as MountingResetGroup; @@ -200,6 +200,13 @@ export function Toolbar() { group={'feet'} customName="toolbar-mounting_calibration-feet" /> + {groupVisibility['toes'] && ( + + )} {groupVisibility['fingers'] && ( void; + onToesSelected?: (side: 'left' | 'right') => void; highlightedRoles: BodyPart[]; mirror: boolean; }) { @@ -114,7 +116,10 @@ export function BodyInteractions({ ...slotPosition, id: slot.id, hidden: - variant === 'tracker-select' && !controlsPosIds.includes(slot.id), + variant === 'tracker-select' && + !controlsPosIds.includes(slot.id) && + slot.id !== 'left-toes' && + slot.id !== 'right-toes', buttonOffset: { left: canvasBox.left - personBox.left, top: canvasBox.top - personBox.top, @@ -211,8 +216,14 @@ export function BodyInteractions({ ({ top, left, height, width, id, hidden, buttonOffset }) => (
onSelectRole((BodyPart as any)[id])} + className={classNames('absolute z-10', 'cursor-pointer')} + onClick={() => { + if (id === 'left-toes' || id === 'right-toes') { + onToesSelected?.(id === 'left-toes' ? 'left' : 'right'); + return; + } + onSelectRole((BodyPart as any)[id]); + }} style={{ top: top + height / 2 - dotsSize / 2 + buttonOffset.top, left: left + width / 2 - dotsSize / 2 + buttonOffset.left, diff --git a/gui/app/src/components/commons/BodyPartIcon.tsx b/gui/app/src/components/commons/BodyPartIcon.tsx index c0ca710053..0178f167c2 100644 --- a/gui/app/src/components/commons/BodyPartIcon.tsx +++ b/gui/app/src/components/commons/BodyPartIcon.tsx @@ -125,8 +125,38 @@ export const mapPart: Record< ), [BodyPart.RIGHT_LITTLE_DISTAL]: ({ width }) => , + [BodyPart.LEFT_BIG_TOE]: renderFootLeft, + [BodyPart.LEFT_INDEX_TOE]: renderFootLeft, + [BodyPart.LEFT_LITTLE_TOE]: renderFootLeft, + [BodyPart.RIGHT_BIG_TOE]: renderFootRight, + [BodyPart.RIGHT_INDEX_TOE]: renderFootRight, + [BodyPart.RIGHT_LITTLE_TOE]: renderFootRight, }; +function renderFootLeft({ + width, + currentLocales, +}: { + width?: number; + currentLocales: string[]; +}) { + if (currentLocales.includes('en-x-owo')) { + return ; + } + return ; +} +function renderFootRight({ + width, + currentLocales, +}: { + width?: number; + currentLocales: string[]; +}) { + if (currentLocales.includes('en-x-owo')) { + return ; + } + return ; +} export function BodyPartIcon({ bodyPart = BodyPart.NONE, width = 24, @@ -138,7 +168,7 @@ export function BodyPartIcon({ return ( - {mapPart[bodyPart]({ width, currentLocales })} + {mapPart[bodyPart]?.({ width, currentLocales })} ); } diff --git a/gui/app/src/components/commons/PersonFrontIcon.tsx b/gui/app/src/components/commons/PersonFrontIcon.tsx index c8061ee674..5d4891cc08 100644 --- a/gui/app/src/components/commons/PersonFrontIcon.tsx +++ b/gui/app/src/components/commons/PersonFrontIcon.tsx @@ -9,6 +9,9 @@ export const SIDES = [ upperLeg: BodyPart.LEFT_UPPER_LEG, lowerLeg: BodyPart.LEFT_LOWER_LEG, foot: BodyPart.LEFT_FOOT, + bigToe: BodyPart.LEFT_BIG_TOE, + indexToe: BodyPart.LEFT_INDEX_TOE, + littleToe: BodyPart.LEFT_LITTLE_TOE, }, { shoulder: BodyPart.RIGHT_SHOULDER, @@ -18,6 +21,9 @@ export const SIDES = [ upperLeg: BodyPart.RIGHT_UPPER_LEG, lowerLeg: BodyPart.RIGHT_LOWER_LEG, foot: BodyPart.RIGHT_FOOT, + bigToe: BodyPart.RIGHT_BIG_TOE, + indexToe: BodyPart.RIGHT_INDEX_TOE, + littleToe: BodyPart.RIGHT_LITTLE_TOE, }, ]; @@ -121,6 +127,14 @@ export function PersonFrontIcon({ mirror = true }: { mirror?: boolean }) { id={BodyPart[SIDES[right].foot]} /> + + + ); } diff --git a/gui/app/src/components/home/ResetButton.tsx b/gui/app/src/components/home/ResetButton.tsx index ebe4018d64..659632a29c 100644 --- a/gui/app/src/components/home/ResetButton.tsx +++ b/gui/app/src/components/home/ResetButton.tsx @@ -23,6 +23,7 @@ export function ResetButtonIcon(options: UseResetOptions) { if (options.type === ResetType.MOUNTING) { if (options.group === 'default') return ; if (options.group === 'feet') return ; + if (options.group === 'toes') return ; if (options.group === 'fingers') return ; } } diff --git a/gui/app/src/components/onboarding/BodyAssignment.tsx b/gui/app/src/components/onboarding/BodyAssignment.tsx index b264d815f7..880d327432 100644 --- a/gui/app/src/components/onboarding/BodyAssignment.tsx +++ b/gui/app/src/components/onboarding/BodyAssignment.tsx @@ -26,6 +26,16 @@ export const LOWER_BODY = new Set([ BodyPart.RIGHT_FOOT, ...LEGS_PARTS, ]); + +export const TOES_PARTS = new Set([ + BodyPart.LEFT_BIG_TOE, + BodyPart.RIGHT_BIG_TOE, + BodyPart.LEFT_INDEX_TOE, + BodyPart.RIGHT_INDEX_TOE, + BodyPart.LEFT_LITTLE_TOE, + BodyPart.RIGHT_LITTLE_TOE, +]); + export const SPINE_PARTS = [ BodyPart.UPPER_CHEST, BodyPart.CHEST, @@ -35,6 +45,12 @@ export const SPINE_PARTS = [ export const ASSIGNMENT_RULES: Partial< Record > = { + [BodyPart.LEFT_BIG_TOE]: [BodyPart.LEFT_FOOT], + [BodyPart.LEFT_INDEX_TOE]: [BodyPart.LEFT_FOOT], + [BodyPart.LEFT_LITTLE_TOE]: [BodyPart.LEFT_FOOT], + [BodyPart.RIGHT_BIG_TOE]: [BodyPart.RIGHT_FOOT], + [BodyPart.RIGHT_INDEX_TOE]: [BodyPart.RIGHT_FOOT], + [BodyPart.RIGHT_LITTLE_TOE]: [BodyPart.RIGHT_FOOT], [BodyPart.LEFT_FOOT]: [ BodyPart.LEFT_LOWER_LEG, BodyPart.LEFT_UPPER_LEG, @@ -93,6 +109,7 @@ export const ASSIGNMENT_MODES: Record = { ...SPINE_PARTS, ...ARMS_PARTS, ...LEGS_PARTS, + ...TOES_PARTS, ], }; @@ -100,6 +117,7 @@ export function BodyAssignment({ assignMode, mirror, onRoleSelected, + onToesSelected, rolesWithErrors = {}, highlightedRoles = [], onlyAssigned = false, @@ -111,6 +129,7 @@ export function BodyAssignment({ rolesWithErrors?: Partial>; highlightedRoles?: BodyPart[]; onRoleSelected: (role: BodyPart) => void; + onToesSelected?: (side: 'left' | 'right') => void; width?: number; dotSize?: number; }) { @@ -159,6 +178,7 @@ export function BodyAssignment({ assignedRoles={assignedRoles} highlightedRoles={highlightedRoles} onSelectRole={onRoleSelected} + onToesSelected={onToesSelected} leftControls={
diff --git a/gui/app/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx b/gui/app/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx index eaf0aba3dc..a0c2e03efa 100644 --- a/gui/app/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx +++ b/gui/app/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx @@ -148,6 +148,60 @@ export const mapPart: Record< ), [BodyPart.RIGHT_LITTLE_DISTAL]: ({ width }) => , + [BodyPart.LEFT_BIG_TOE]: ({ width, currentLocales }) => + currentLocales.includes('en-x-owo') ? ( + + ) : ( + + ), + [BodyPart.LEFT_INDEX_TOE]: ({ width, currentLocales }) => + currentLocales.includes('en-x-owo') ? ( + + ) : ( + + ), + [BodyPart.LEFT_LITTLE_TOE]: ({ width, currentLocales }) => + currentLocales.includes('en-x-owo') ? ( + + ) : ( + + ), + [BodyPart.RIGHT_BIG_TOE]: ({ width, currentLocales }) => + currentLocales.includes('en-x-owo') ? ( + + ) : ( + + ), + [BodyPart.RIGHT_INDEX_TOE]: ({ width, currentLocales }) => + currentLocales.includes('en-x-owo') ? ( + + ) : ( + + ), + [BodyPart.RIGHT_LITTLE_TOE]: ({ width, currentLocales }) => + currentLocales.includes('en-x-owo') ? ( + + ) : ( + + ), }; export function MountingBodyPartIcon({ @@ -158,7 +212,9 @@ export function MountingBodyPartIcon({ width?: number; }) { const { currentLocales } = useLocaleConfig(); - return mapPart[bodyPart]({ width, currentLocales }); + return ( + mapPart[bodyPart]?.({ width, currentLocales }) || + ); } function PieSliceOfFeet({ diff --git a/gui/app/src/components/onboarding/pages/trackers-assign/ToeAssignmentModal.tsx b/gui/app/src/components/onboarding/pages/trackers-assign/ToeAssignmentModal.tsx new file mode 100644 index 0000000000..4491c01d11 --- /dev/null +++ b/gui/app/src/components/onboarding/pages/trackers-assign/ToeAssignmentModal.tsx @@ -0,0 +1,315 @@ +import classNames from 'classnames'; +import ReactModal from 'react-modal'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + AssignTrackerRequestT, + BodyPart, + QuatT, + RpcMessage, +} from 'solarxr-protocol'; +import { Button } from '@/components/commons/Button'; +import { Typography } from '@/components/commons/Typography'; +import { TrackerSelectionMenu } from './TrackerSelectionMenu'; +import { useLocalization } from '@fluent/react'; +import { useAtomValue } from 'jotai'; +import { assignedTrackersAtom, FlatDeviceTracker } from '@/store/app-store'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { FootIcon } from '@/components/commons/icon/FootIcon'; + +export function ToeAssignmentModal({ + isOpen, + side, + onClose, +}: { + isOpen: boolean; + side: 'left' | 'right'; + onClose: () => void; +}) { + const { l10n } = useLocalization(); + const { sendRPCPacket } = useWebsocketAPI(); + const assignedTrackers = useAtomValue(assignedTrackersAtom); + const [selectedRole, setSelectedRole] = useState(BodyPart.NONE); + + // Defined inside component to ensure BodyPart enum is fully resolved + // (Vite's CJS-to-ESM dep cache may not include newly added enum values) + const TOE_LABELS: Record = useMemo( + () => ({ + [BodyPart.LEFT_BIG_TOE]: 'Left Big Toe', + [BodyPart.LEFT_INDEX_TOE]: 'Left Index Toe', + [BodyPart.LEFT_LITTLE_TOE]: 'Left little toe', + [BodyPart.RIGHT_BIG_TOE]: 'Right Big Toe', + [BodyPart.RIGHT_INDEX_TOE]: 'Right Index Toe', + [BodyPart.RIGHT_LITTLE_TOE]: 'Right little toe', + }), + [] + ); + + const TOE_PARTS = useMemo( + () => ({ + left: [ + { + part: BodyPart.LEFT_BIG_TOE, + cx: 110, + cy: 30, + }, + { + part: BodyPart.LEFT_INDEX_TOE, + cx: 73, + cy: 40, + }, + { + part: BodyPart.LEFT_LITTLE_TOE, + cx: 50, + cy: 62, + }, + ], + right: [ + { + part: BodyPart.RIGHT_BIG_TOE, + cx: 77, + cy: 30, + }, + { + part: BodyPart.RIGHT_INDEX_TOE, + cx: 114, + cy: 40, + }, + { + part: BodyPart.RIGHT_LITTLE_TOE, + cx: 137, + cy: 62, + }, + ], + }), + [] + ); + + const toeParts = TOE_PARTS[side]; + const DOT_SIZE = 18; + + const canvasRef = useRef(null); + const containerRef = useRef(null); + const cardsRef = useRef(null); + const footRef = useRef(null); + + const drawLines = useCallback(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + const cards = cardsRef.current; + const foot = footRef.current; + if (!canvas || !container || !cards || !foot) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + const containerRect = container.getBoundingClientRect(); + + ctx.strokeStyle = '#608AAB'; + ctx.lineWidth = 2; + + toeParts.forEach(({ part, cx, cy }) => { + const card = cards.querySelector(`[data-part="${part}"]`) as HTMLElement; + if (!card) return; + + const cardRect = card.getBoundingClientRect(); + const footRect = foot.getBoundingClientRect(); + + // Card connection point: right edge, vertical center + const cardX = cardRect.right - containerRect.left; + const cardY = cardRect.top + cardRect.height / 2 - containerRect.top; + + // Dot position relative to container + const dotX = footRect.left - containerRect.left + cx; + const dotY = footRect.top - containerRect.top + cy; + + ctx.beginPath(); + ctx.moveTo(cardX, cardY); + ctx.lineTo(cardX + 20, cardY); + ctx.lineTo(dotX, dotY); + ctx.stroke(); + }); + }, [toeParts]); + + useEffect(() => { + // Delay to let modal finish rendering/animating + const timer = setTimeout(drawLines, 100); + window.addEventListener('resize', drawLines); + return () => { + clearTimeout(timer); + window.removeEventListener('resize', drawLines); + }; + }, [drawLines, isOpen]); + + const trackerPartGrouped = useMemo( + () => + assignedTrackers.reduce<{ [key: number]: FlatDeviceTracker[] }>( + (curr, td) => { + const key = td.tracker.info?.bodyPart || BodyPart.NONE; + return { + ...curr, + [key]: [...(curr[key] || []), td], + }; + }, + {} + ), + [assignedTrackers] + ); + + const assignedRoles = useMemo( + () => + assignedTrackers.map( + ({ tracker }) => tracker.info?.bodyPart || BodyPart.NONE + ), + [assignedTrackers] + ); + + const onTrackerSelected = (tracker: FlatDeviceTracker | null) => { + const assign = ( + role: BodyPart, + rotation: QuatT | null, + trackerId: number + ) => { + const assignreq = new AssignTrackerRequestT(); + assignreq.bodyPosition = role; + assignreq.mountingOrientation = rotation; + assignreq.trackerId = trackerId; + sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); + }; + + // Unassign existing trackers from this role + (trackerPartGrouped[selectedRole] || []).forEach((td) => + assign( + BodyPart.NONE, + td.tracker.info?.mountingOrientation || null, + td.tracker.trackerId + ) + ); + + if (!tracker) { + setSelectedRole(BodyPart.NONE); + return; + } + assign( + selectedRole, + tracker.tracker.info?.mountingOrientation || null, + tracker.tracker.trackerId + ); + setSelectedRole(BodyPart.NONE); + }; + + const sideLabel = side === 'left' ? 'Left' : 'Right'; + + return ( + <> + setSelectedRole(BodyPart.NONE)} + onTrackerSelected={onTrackerSelected} + /> + +
+ + {sideLabel} Foot ΓÇö Toe Assignment + + + Click a toe position to assign a tracker + + +
+ + {/* Toe cards */} +
+ {toeParts.map(({ part }) => ( +
setSelectedRole(part)} + > + + {TOE_LABELS[part] || BodyPart[part]} + + + {trackerPartGrouped[part]?.[0] + ? `${trackerPartGrouped[part][0].tracker.info?.customName || trackerPartGrouped[part][0].tracker.info?.displayName}` + : l10n.getString('tracker-part_card-unassigned')} + +
+ ))} +
+ + {/* Foot graphic with dots */} +
+ + {toeParts.map(({ part, cx, cy }) => ( +
setSelectedRole(part)} + style={{ + top: cy - DOT_SIZE / 2, + left: cx - DOT_SIZE / 2, + }} + > +
+
+ ))} +
+
+ +
+ +
+
+ + + ); +} diff --git a/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 37e7a4ab64..191c7ec97f 100644 --- a/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -28,6 +28,7 @@ import { defaultConfig, useConfig } from '@/hooks/config'; import { playTapSetupSound } from '@/sounds/sounds'; import { useBreakpoint } from '@/hooks/breakpoint'; import { TrackerAssignOptions } from './TrackerAssignOptions'; +import { ToeAssignmentModal } from './ToeAssignmentModal'; import { useAtomValue } from 'jotai'; import { assignedTrackersAtom, @@ -61,6 +62,7 @@ export function TrackersAssignPage() { }>({ defaultValues }); const { mirrorView } = watch(); const [selectedRole, setSelectRole] = useState(BodyPart.NONE); + const [toeSide, setToeSide] = useState<'left' | 'right' | null>(null); const assignedTrackers = useAtomValue(assignedTrackersAtom); const trackers = useAtomValue(flatTrackersAtom); @@ -319,11 +321,19 @@ export function TrackersAssignPage() { assignMode={config?.assignMode ?? null} mirror={mirrorView} onRoleSelected={tryOpenChokerWarning} + onToesSelected={setToeSide} />
+ {toeSide && ( + setToeSide(null)} + /> + )} ); } diff --git a/gui/app/src/hooks/body-parts.ts b/gui/app/src/hooks/body-parts.ts index a2ac2766f0..b7035cc570 100644 --- a/gui/app/src/hooks/body-parts.ts +++ b/gui/app/src/hooks/body-parts.ts @@ -54,6 +54,21 @@ export const ALL_BODY_PARTS = [ BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + BodyPart.LEFT_BIG_TOE, + BodyPart.LEFT_INDEX_TOE, + BodyPart.LEFT_LITTLE_TOE, + BodyPart.RIGHT_BIG_TOE, + BodyPart.RIGHT_INDEX_TOE, + BodyPart.RIGHT_LITTLE_TOE, +]; + +export const TOE_BODY_PARTS = [ + BodyPart.LEFT_BIG_TOE, + BodyPart.LEFT_INDEX_TOE, + BodyPart.LEFT_LITTLE_TOE, + BodyPart.RIGHT_BIG_TOE, + BodyPart.RIGHT_INDEX_TOE, + BodyPart.RIGHT_LITTLE_TOE, ]; export const FEET_BODY_PARTS = [BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT]; export const FINGER_BODY_PARTS = [ diff --git a/gui/app/src/hooks/reset.ts b/gui/app/src/hooks/reset.ts index 14c1add812..98e9beab42 100644 --- a/gui/app/src/hooks/reset.ts +++ b/gui/app/src/hooks/reset.ts @@ -10,13 +10,13 @@ import { import { useWebsocketAPI } from './websocket-api'; import { useAtomValue } from 'jotai'; import { assignedTrackersAtom, serverGuardsAtom } from '@/store/app-store'; -import { FEET_BODY_PARTS, FINGER_BODY_PARTS } from './body-parts'; +import { FEET_BODY_PARTS, FINGER_BODY_PARTS, TOE_BODY_PARTS } from './body-parts'; import { useLocaleConfig } from '@/i18n/config'; import * as Sentry from '@sentry/react'; export type ResetBtnStatus = 'idle' | 'counting' | 'finished'; -export type MountingResetGroup = 'default' | 'feet' | 'fingers'; +export type MountingResetGroup = 'default' | 'feet' | 'fingers' | 'toes'; export type UseResetOptions = | { type: ResetType.FULL | ResetType.YAW } | { type: ResetType.MOUNTING; group: MountingResetGroup }; @@ -24,6 +24,7 @@ export type UseResetOptions = export const BODY_PARTS_GROUPS: Record = { default: [], feet: FEET_BODY_PARTS, + toes: TOE_BODY_PARTS, fingers: FINGER_BODY_PARTS, }; diff --git a/gui/app/src/utils/skeletonHelper.ts b/gui/app/src/utils/skeletonHelper.ts index 8d8c379d9c..92334b433d 100644 --- a/gui/app/src/utils/skeletonHelper.ts +++ b/gui/app/src/utils/skeletonHelper.ts @@ -12,7 +12,7 @@ const _matrixWorldInv = new Matrix4(); export class BasedSkeletonHelper extends LineSegments2 { isSkeletonHelper: boolean; - root: Object3D; + root: Object3D bones: Bone[]; readonly type = 'SkeletonHelper'; @@ -239,6 +239,17 @@ export class BoneKind extends Bone { case BodyPart.RIGHT_LITTLE_INTERMEDIATE: case BodyPart.RIGHT_LITTLE_DISTAL: return new Color('pink'); + case BodyPart.LEFT_BIG_TOE: + case BodyPart.LEFT_INDEX_TOE: + case BodyPart.LEFT_MIDDLE_TOE: + case BodyPart.LEFT_RING_TOE: + case BodyPart.LEFT_LITTLE_TOE: + case BodyPart.RIGHT_BIG_TOE: + case BodyPart.RIGHT_INDEX_TOE: + case BodyPart.RIGHT_MIDDLE_TOE: + case BodyPart.RIGHT_RING_TOE: + case BodyPart.RIGHT_LITTLE_TOE: + return new Color('pink'); } } @@ -274,10 +285,21 @@ export class BoneKind extends Bone { case BodyPart.RIGHT_LOWER_LEG: return [BodyPart.RIGHT_FOOT]; case BodyPart.LEFT_FOOT: - return []; + return [ + BodyPart.LEFT_BIG_TOE, + BodyPart.LEFT_INDEX_TOE, + BodyPart.LEFT_MIDDLE_TOE, + BodyPart.LEFT_RING_TOE, + BodyPart.LEFT_LITTLE_TOE, + ]; case BodyPart.RIGHT_FOOT: - return []; - + return [ + BodyPart.RIGHT_BIG_TOE, + BodyPart.RIGHT_INDEX_TOE, + BodyPart.RIGHT_MIDDLE_TOE, + BodyPart.RIGHT_RING_TOE, + BodyPart.RIGHT_LITTLE_TOE, + ]; case BodyPart.LEFT_SHOULDER: return [BodyPart.LEFT_UPPER_ARM]; case BodyPart.RIGHT_SHOULDER: @@ -367,6 +389,18 @@ export class BoneKind extends Bone { return [BodyPart.RIGHT_LITTLE_DISTAL]; case BodyPart.RIGHT_LITTLE_DISTAL: return []; + return []; + case BodyPart.LEFT_BIG_TOE: + case BodyPart.LEFT_INDEX_TOE: + case BodyPart.LEFT_MIDDLE_TOE: + case BodyPart.LEFT_RING_TOE: + case BodyPart.LEFT_LITTLE_TOE: + case BodyPart.RIGHT_BIG_TOE: + case BodyPart.RIGHT_INDEX_TOE: + case BodyPart.RIGHT_MIDDLE_TOE: + case BodyPart.RIGHT_RING_TOE: + case BodyPart.RIGHT_LITTLE_TOE: + return []; } } @@ -479,6 +513,27 @@ export class BoneKind extends Bone { return BodyPart.RIGHT_LITTLE_PROXIMAL; case BodyPart.RIGHT_LITTLE_DISTAL: return BodyPart.RIGHT_LITTLE_INTERMEDIATE; + + case BodyPart.LEFT_BIG_TOE: + return BodyPart.LEFT_FOOT; + case BodyPart.LEFT_INDEX_TOE: + return BodyPart.LEFT_FOOT; + case BodyPart.LEFT_MIDDLE_TOE: + return BodyPart.LEFT_FOOT; + case BodyPart.LEFT_RING_TOE: + return BodyPart.LEFT_FOOT; + case BodyPart.LEFT_LITTLE_TOE: + return BodyPart.LEFT_FOOT; + case BodyPart.RIGHT_BIG_TOE: + return BodyPart.RIGHT_FOOT; + case BodyPart.RIGHT_INDEX_TOE: + return BodyPart.RIGHT_FOOT; + case BodyPart.RIGHT_MIDDLE_TOE: + return BodyPart.RIGHT_FOOT; + case BodyPart.RIGHT_RING_TOE: + return BodyPart.RIGHT_FOOT; + case BodyPart.RIGHT_LITTLE_TOE: + return BodyPart.RIGHT_FOOT; } } } diff --git a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt index bb50378588..73cc6c936b 100644 --- a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt +++ b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt @@ -76,4 +76,19 @@ object ResetBodyParts { BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, ) + + val LEFT_TOES = setOf( + BodyPart.LEFT_BIG_TOE, + BodyPart.LEFT_INDEX_TOE, + BodyPart.LEFT_MIDDLE_TOE, + BodyPart.LEFT_RING_TOE, + BodyPart.LEFT_LITTLE_TOE, + ) + val RIGHT_TOES = setOf( + BodyPart.RIGHT_BIG_TOE, + BodyPart.RIGHT_INDEX_TOE, + BodyPart.RIGHT_MIDDLE_TOE, + BodyPart.RIGHT_RING_TOE, + BodyPart.RIGHT_LITTLE_TOE, + ) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index 3d5e38e8d9..fdf617c029 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -63,6 +63,22 @@ val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( BodyPart.RIGHT_HIP to arrayOf(BodyPart.RIGHT_UPPER_LEG), BodyPart.RIGHT_UPPER_LEG to arrayOf(BodyPart.RIGHT_LOWER_LEG), BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), + + BodyPart.LEFT_FOOT to arrayOf( + BodyPart.LEFT_BIG_TOE, + BodyPart.LEFT_INDEX_TOE, + BodyPart.LEFT_MIDDLE_TOE, + BodyPart.LEFT_RING_TOE, + BodyPart.LEFT_LITTLE_TOE, + ), + + BodyPart.RIGHT_FOOT to arrayOf( + BodyPart.RIGHT_BIG_TOE, + BodyPart.RIGHT_INDEX_TOE, + BodyPart.RIGHT_MIDDLE_TOE, + BodyPart.RIGHT_RING_TOE, + BodyPart.RIGHT_LITTLE_TOE, + ), ), ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt index 52970934d4..13b16e2593 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt @@ -9,5 +9,10 @@ private val FINGER_OFFSETS = ( iterateBodyPartHierarchy(BodyPart.RIGHT_HAND, true) ).map { it.second }.associateWith { Vector3(0f, -0.025f, 0f) } +private val TOE_OFFSETS = ( + iterateBodyPartHierarchy(BodyPart.LEFT_FOOT, true) + + iterateBodyPartHierarchy(BodyPart.RIGHT_FOOT, true) + ).map { it.second }.associateWith { Vector3(0f, 0f, -0.025f) } + val DEFAULT_BONE_OFFSETS: BodyPartMap = DEFAULT_PROPORTIONS.toBoneOffsets() - .mutate { offsets -> for ((bodyPart, offset) in FINGER_OFFSETS) offsets[bodyPart] = offset } + .mutate { offsets -> for ((bodyPart, offset) in FINGER_OFFSETS + TOE_OFFSETS) offsets[bodyPart] = offset } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 97c6b40bfd..1c2235c4cf 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -11,6 +11,7 @@ import dev.slimevr.skeleton.processors.BoneYawFallbackProcessor import dev.slimevr.skeleton.processors.BoneYawRollAlignProcessor import dev.slimevr.skeleton.processors.FingerImputeProcessor import dev.slimevr.skeleton.processors.SpineImputeProcessor +import dev.slimevr.skeleton.processors.ToeDirectLinkProcessor import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -153,6 +154,7 @@ class Skeleton( BoneYawRollAlignProcessor(ctx.config.settings), BoneDirectLinkProcessor(), FingerImputeProcessor(), + ToeDirectLinkProcessor(), BonePredictionProcessor(ctx.config.settings), BoneSmoothingProcessor(ctx.config.settings), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/toe-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/toe-direct-link.kt new file mode 100644 index 0000000000..2e79edacf3 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/toe-direct-link.kt @@ -0,0 +1,44 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.BodyPartMap +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles rotations of inactive toe bones. + */ +class ToeDirectLinkProcessor : SkeletonProcessor { + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val toesToSource = arrayOf( + BodyPart.LEFT_BIG_TOE to BodyPart.LEFT_FOOT, + BodyPart.LEFT_INDEX_TOE to BodyPart.LEFT_BIG_TOE, + BodyPart.LEFT_MIDDLE_TOE to BodyPart.LEFT_INDEX_TOE, + BodyPart.LEFT_RING_TOE to BodyPart.LEFT_MIDDLE_TOE, + BodyPart.LEFT_LITTLE_TOE to BodyPart.LEFT_RING_TOE, + + BodyPart.RIGHT_BIG_TOE to BodyPart.RIGHT_FOOT, + BodyPart.RIGHT_INDEX_TOE to BodyPart.RIGHT_BIG_TOE, + BodyPart.RIGHT_MIDDLE_TOE to BodyPart.RIGHT_INDEX_TOE, + BodyPart.RIGHT_RING_TOE to BodyPart.RIGHT_MIDDLE_TOE, + BodyPart.RIGHT_LITTLE_TOE to BodyPart.RIGHT_RING_TOE, + ) + + override fun process(state: SkeletonState): SkeletonState { + val updatedBoneInputs = BodyPartMap(state.boneInputs) + + for ((bodyPart, source) in toesToSource) { + val bone = updatedBoneInputs.getValue(bodyPart) + if (bone.isActive) continue + + val sourceBone = updatedBoneInputs[source] + updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) + } + + return state.copy(boneInputs = updatedBoneInputs) + } +} diff --git a/server/core/src/main/java/dev/slimevr/util/side.kt b/server/core/src/main/java/dev/slimevr/util/side.kt index 79d030308d..b0bea21539 100644 --- a/server/core/src/main/java/dev/slimevr/util/side.kt +++ b/server/core/src/main/java/dev/slimevr/util/side.kt @@ -1,8 +1,5 @@ package dev.slimevr.util -/** - * Used to represent the side of the skeleton a bone is on. - */ enum class Side { LEFT, RIGHT, diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index 3118747a63..a9cd8630bc 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -58,6 +58,8 @@ val BODY_PART_TO_UNITY_BONE: Map = mapOf( BodyPart.RIGHT_LITTLE_PROXIMAL to "RightLittleProximal", BodyPart.RIGHT_LITTLE_INTERMEDIATE to "RightLittleIntermediate", BodyPart.RIGHT_LITTLE_DISTAL to "RightLittleDistal", + BodyPart.LEFT_BIG_TOE to "LeftToes", + BodyPart.RIGHT_BIG_TOE to "RightToes", ) // HIP-rooted hierarchy. VMC/Unity expects this; our skeleton is HEAD-rooted. @@ -111,6 +113,12 @@ val VMC_HIERARCHY_MAP: Map> = mapOf( BodyPart.RIGHT_RING_INTERMEDIATE to arrayOf(BodyPart.RIGHT_RING_DISTAL), BodyPart.RIGHT_LITTLE_PROXIMAL to arrayOf(BodyPart.RIGHT_LITTLE_INTERMEDIATE), BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), + BodyPart.LEFT_FOOT to arrayOf( + BodyPart.LEFT_BIG_TOE, + ), + BodyPart.RIGHT_FOOT to arrayOf( + BodyPart.RIGHT_BIG_TOE, + ), ) private suspend fun SequenceScope>.visitVMC(parent: BodyPart?, bone: BodyPart) { @@ -146,6 +154,7 @@ val VMC_MIRROR_BONE_PAIRS: List> = listOf( BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_DISTAL, + BodyPart.LEFT_BIG_TOE to BodyPart.RIGHT_BIG_TOE, ) val VMC_MIRROR_BONES: BodyPartMap = BodyPartMap( @@ -177,6 +186,12 @@ val VMC_REST_ROTATIONS: BodyPartMap = run { BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, ) + val leftToes = listOf( + BodyPart.LEFT_BIG_TOE, + ) + val rightToes = listOf( + BodyPart.RIGHT_BIG_TOE, + ) BodyPartMap( mapOf( BodyPart.LEFT_FOOT to foot, @@ -189,7 +204,9 @@ val VMC_REST_ROTATIONS: BodyPartMap = run { BodyPart.RIGHT_HAND to rightArm, ) + leftFingers.associateWith { leftArm } + - rightFingers.associateWith { rightArm }, + rightFingers.associateWith { rightArm } + + leftToes.associateWith { foot } + + rightToes.associateWith { foot }, ) } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index 1faa458ca2..ab7ddefec4 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -27,7 +27,7 @@ internal fun buildOutgoingBundle( bones: Map, outputTrackerToggle: OutputTrackerToggleManager, ): OscBundle? { - val messages = buildList { + val messages = buildList { for ((bodyPart, trackerId) in trackerIdsByBodyPart) { if (bodyPart !in outputTrackerToggle.context.state.value.trackers) continue @@ -51,6 +51,8 @@ internal fun buildOutgoingBundle( ), ) } + + addAll(buildToeMessages(bones)) } return messages.takeIf { it.isNotEmpty() }?.let { OscBundle(1L, it) } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt new file mode 100644 index 0000000000..1f7aac23d3 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/toe-plugin-output-encoder.kt @@ -0,0 +1,113 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.skeleton.BoneState +import dev.slimevr.util.Side +import dev.slimevr.util.opposite +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import kotlin.math.* + +private const val ABSOLUTE_SPLAY_THRESHOLD_ANGLE = 7 +private const val MINIMUM_TIP_TOE_PITCH = -14 +private const val MINIMUM_BENDING_PITCH = 15 +private const val MAXIMUM_ABSOLUTE_TOE_RANGE = 90 + +internal fun buildToeMessages(bones: Map): List { + val messages = mutableListOf() + + // LEFT FOOT + TOES + val leftFoot = bones[BodyPart.LEFT_FOOT] + if (leftFoot != null) { + val leftToes = listOf( + bones[BodyPart.LEFT_BIG_TOE], + bones[BodyPart.LEFT_INDEX_TOE], + bones[BodyPart.LEFT_MIDDLE_TOE], + bones[BodyPart.LEFT_RING_TOE], + bones[BodyPart.LEFT_LITTLE_TOE], + ) + processToesForFoot(leftFoot, leftToes, Side.LEFT, messages) + } + + // RIGHT FOOT + TOES + val rightFoot = bones[BodyPart.RIGHT_FOOT] + if (rightFoot != null) { + val rightToes = listOf( + bones[BodyPart.RIGHT_BIG_TOE], + bones[BodyPart.RIGHT_INDEX_TOE], + bones[BodyPart.RIGHT_MIDDLE_TOE], + bones[BodyPart.RIGHT_RING_TOE], + bones[BodyPart.RIGHT_LITTLE_TOE], + ) + processToesForFoot(rightFoot, rightToes, Side.RIGHT, messages) + } + + return messages +} + +private fun processToesForFoot( + foot: BoneState, + toeBones: List, + side: Side, + messages: MutableList, +) { + var lastAssigned: BoneState? = null + + for ((segmentIndex, toe) in toeBones.withIndex()) { + if (toe != null) { + lastAssigned = toe + } + + if (lastAssigned == null) continue + + val splayDirection = if (segmentIndex > 0) side else side.opposite + + processToe(foot, lastAssigned, side, segmentIndex, splayDirection, messages) + } +} + +private val Side.oscName: String + get() = when (this) { + Side.LEFT -> "Left" + Side.RIGHT -> "Right" + } + +private fun processToe( + foot: BoneState, + toe: BoneState, + side: Side, + toeNumber: Int, + splayDirection: Side, + messages: MutableList, +) { + val oscToeNumber = toeNumber + 1 + val footRot = foot.rotation + val toeRot = toe.rotation + val currentRelative = footRot.inv() * toeRot + + val euler = currentRelative.toEulerAngles(EulerOrder.XYZ) + + val pitch = euler.z + val yaw = euler.y + val tipToe = pitch < MINIMUM_TIP_TOE_PITCH + val bending = pitch > MINIMUM_BENDING_PITCH && !tipToe + val splayed = when (splayDirection) { + Side.LEFT -> yaw < -ABSOLUTE_SPLAY_THRESHOLD_ANGLE + Side.RIGHT -> yaw > ABSOLUTE_SPLAY_THRESHOLD_ANGLE + } + val toeCurlValue = (pitch / MAXIMUM_ABSOLUTE_TOE_RANGE).coerceIn(-1f, 1f) + val toeSplayValue = (yaw / MAXIMUM_ABSOLUTE_TOE_RANGE).coerceIn(-1f, 1f) + messages.addAll( + listOf( + OscContent.Message(OscMessage("/avatar/parameters/TipToes${side.oscName}", listOf(if (tipToe) OscArg.True else OscArg.False))), + OscContent.Message(OscMessage("/avatar/parameters/ToeBent${side.oscName}${oscToeNumber}Bool", listOf(if (bending) OscArg.True else OscArg.False))), + OscContent.Message(OscMessage("/avatar/parameters/ToeSplay${side.oscName}$oscToeNumber", listOf(if (splayed) OscArg.True else OscArg.False))), + OscContent.Message(OscMessage("/avatar/parameters/Toe${side.oscName}${oscToeNumber}Float", listOf(OscArg.Float(toeCurlValue)))), + OscContent.Message(OscMessage("/avatar/parameters/ToeSplay${side.oscName}${oscToeNumber}Float", listOf(OscArg.Float(toeSplayValue)))), + ), + ) +} diff --git a/server/core/src/test/java/dev/slimevr/skeleton/ToeDirectLinkProcessorTest.kt b/server/core/src/test/java/dev/slimevr/skeleton/ToeDirectLinkProcessorTest.kt new file mode 100644 index 0000000000..9d78cf5783 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/skeleton/ToeDirectLinkProcessorTest.kt @@ -0,0 +1,320 @@ +package dev.slimevr.skeleton + +import dev.slimevr.skeleton.processors.ToeDirectLinkProcessor +import io.github.axisangles.ktmath.Quaternion +import org.junit.jupiter.api.Test +import solarxr_protocol.datatypes.BodyPart +import kotlin.test.assertTrue + +class ToeDirectLinkProcessorTest { + @Test + fun `test missing all toe trackers`() { + val processor = ToeDirectLinkProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutate { map -> + map[BodyPart.LEFT_FOOT] = map.getValue(BodyPart.LEFT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(10f, 40f, 15f), + isActive = true, + ) + map[BodyPart.RIGHT_FOOT] = map.getValue(BodyPart.RIGHT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(20f, 50f, 25f), + isActive = true, + ) + } + + val state = SkeletonState( + boneInputs = inputs, + skeletonHeight = 1.7f, + paused = false, + ) + + val newInputs = processor.process(state) + + val leftBigToeIsSameRotationAsLeftFoot = + newInputs.boneInputs[BodyPart.LEFT_BIG_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_FOOT]?.rawRotation + val leftIndexToeIsSameRotationAsLeftBigToe = + newInputs.boneInputs[BodyPart.LEFT_INDEX_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_BIG_TOE]?.rawRotation + val leftMiddleToeIsSameRotationAsLeftIndexToe = + newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_INDEX_TOE]?.rawRotation + val leftRingToeIsSameRotationAsLeftMiddleToe = + newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation + val leftLittleToeIsSameRotationAsLeftRingToe = + newInputs.boneInputs[BodyPart.LEFT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation + + val rightBigToeIsSameRotationAsRightFoot = + newInputs.boneInputs[BodyPart.RIGHT_BIG_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_FOOT]?.rawRotation + val rightIndexToeIsSameRotationAsRightBigToe = + newInputs.boneInputs[BodyPart.RIGHT_INDEX_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_BIG_TOE]?.rawRotation + val rightMiddleToeIsSameRotationAsRightIndexToe = + newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_INDEX_TOE]?.rawRotation + val rightRingToeIsSameRotationAsRightMiddleToe = + newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation + val rightLittleToeIsSameRotationAsRightRingToe = + newInputs.boneInputs[BodyPart.RIGHT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation + + assertTrue( + leftBigToeIsSameRotationAsLeftFoot && + leftIndexToeIsSameRotationAsLeftBigToe && + leftMiddleToeIsSameRotationAsLeftIndexToe && + leftRingToeIsSameRotationAsLeftMiddleToe && + leftLittleToeIsSameRotationAsLeftRingToe, + ) + assertTrue( + rightBigToeIsSameRotationAsRightFoot && + rightIndexToeIsSameRotationAsRightBigToe && + rightMiddleToeIsSameRotationAsRightIndexToe && + rightRingToeIsSameRotationAsRightMiddleToe && + rightLittleToeIsSameRotationAsRightRingToe, + ) + } + + @Test + fun `test missing toe trackers from index toe downwards`() { + val processor = ToeDirectLinkProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutate { map -> + map[BodyPart.LEFT_FOOT] = map.getValue(BodyPart.LEFT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(10f, 40f, 15f), + isActive = true, + ) + map[BodyPart.LEFT_BIG_TOE] = map.getValue(BodyPart.LEFT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(11f, 41f, 16f), + isActive = true, + ) + map[BodyPart.RIGHT_FOOT] = map.getValue(BodyPart.RIGHT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(20f, 50f, 25f), + isActive = true, + ) + map[BodyPart.RIGHT_BIG_TOE] = map.getValue(BodyPart.RIGHT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(21f, 51f, 26f), + isActive = true, + ) + } + + val state = SkeletonState( + boneInputs = inputs, + skeletonHeight = 1.7f, + paused = false, + ) + + val newInputs = processor.process(state) + + val leftIndexToeIsSameRotationAsLeftBigToe = + newInputs.boneInputs[BodyPart.LEFT_INDEX_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_BIG_TOE]?.rawRotation + val leftMiddleToeIsSameRotationAsLeftIndexToe = + newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_INDEX_TOE]?.rawRotation + val leftRingToeIsSameRotationAsLeftMiddleToe = + newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation + val leftLittleToeIsSameRotationAsLeftRingToe = + newInputs.boneInputs[BodyPart.LEFT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation + + val rightIndexToeIsSameRotationAsRightBigToe = + newInputs.boneInputs[BodyPart.RIGHT_INDEX_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_BIG_TOE]?.rawRotation + val rightMiddleToeIsSameRotationAsRightIndexToe = + newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_INDEX_TOE]?.rawRotation + val rightRingToeIsSameRotationAsRightMiddleToe = + newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation + val rightLittleToeIsSameRotationAsRightRingToe = + newInputs.boneInputs[BodyPart.RIGHT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation + + assertTrue( + leftIndexToeIsSameRotationAsLeftBigToe && + leftMiddleToeIsSameRotationAsLeftIndexToe && + leftRingToeIsSameRotationAsLeftMiddleToe && + leftLittleToeIsSameRotationAsLeftRingToe, + ) + assertTrue( + rightIndexToeIsSameRotationAsRightBigToe && + rightMiddleToeIsSameRotationAsRightIndexToe && + rightRingToeIsSameRotationAsRightMiddleToe && + rightLittleToeIsSameRotationAsRightRingToe, + ) + } + + @Test + fun `test missing toe trackers from middle toe downwards`() { + val processor = ToeDirectLinkProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutate { map -> + map[BodyPart.LEFT_FOOT] = map.getValue(BodyPart.LEFT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(10f, 40f, 15f), + isActive = true, + ) + map[BodyPart.LEFT_BIG_TOE] = map.getValue(BodyPart.LEFT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(11f, 41f, 16f), + isActive = true, + ) + map[BodyPart.LEFT_INDEX_TOE] = map.getValue(BodyPart.LEFT_INDEX_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(12f, 42f, 17f), + isActive = true, + ) + + map[BodyPart.RIGHT_FOOT] = map.getValue(BodyPart.RIGHT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(20f, 50f, 25f), + isActive = true, + ) + map[BodyPart.RIGHT_BIG_TOE] = map.getValue(BodyPart.RIGHT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(21f, 51f, 26f), + isActive = true, + ) + map[BodyPart.RIGHT_INDEX_TOE] = map.getValue(BodyPart.RIGHT_INDEX_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(22f, 52f, 27f), + isActive = true, + ) + } + + val state = SkeletonState( + boneInputs = inputs, + skeletonHeight = 1.7f, + paused = false, + ) + + val newInputs = processor.process(state) + + val leftMiddleToeIsSameRotationAsLeftIndexToe = + newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_INDEX_TOE]?.rawRotation + val leftRingToeIsSameRotationAsLeftMiddleToe = + newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation + val leftLittleToeIsSameRotationAsLeftRingToe = + newInputs.boneInputs[BodyPart.LEFT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation + + val rightMiddleToeIsSameRotationAsRightIndexToe = + newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_INDEX_TOE]?.rawRotation + val rightRingToeIsSameRotationAsRightMiddleToe = + newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation + val rightLittleToeIsSameRotationAsRightRingToe = + newInputs.boneInputs[BodyPart.RIGHT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation + + assertTrue( + leftMiddleToeIsSameRotationAsLeftIndexToe && + leftRingToeIsSameRotationAsLeftMiddleToe && + leftLittleToeIsSameRotationAsLeftRingToe, + ) + assertTrue( + rightMiddleToeIsSameRotationAsRightIndexToe && + rightRingToeIsSameRotationAsRightMiddleToe && + rightLittleToeIsSameRotationAsRightRingToe, + ) + } + + @Test + fun `test missing toe trackers from ring toe downwards`() { + val processor = ToeDirectLinkProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutate { map -> + map[BodyPart.LEFT_FOOT] = map.getValue(BodyPart.LEFT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(10f, 40f, 15f), + isActive = true, + ) + map[BodyPart.LEFT_BIG_TOE] = map.getValue(BodyPart.LEFT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(11f, 41f, 16f), + isActive = true, + ) + map[BodyPart.LEFT_INDEX_TOE] = map.getValue(BodyPart.LEFT_INDEX_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(12f, 42f, 17f), + isActive = true, + ) + map[BodyPart.LEFT_MIDDLE_TOE] = map.getValue(BodyPart.LEFT_MIDDLE_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(13f, 43f, 18f), + isActive = true, + ) + + map[BodyPart.RIGHT_FOOT] = map.getValue(BodyPart.RIGHT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(20f, 50f, 25f), + isActive = true, + ) + map[BodyPart.RIGHT_BIG_TOE] = map.getValue(BodyPart.RIGHT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(21f, 51f, 26f), + isActive = true, + ) + map[BodyPart.RIGHT_INDEX_TOE] = map.getValue(BodyPart.RIGHT_INDEX_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(22f, 52f, 27f), + isActive = true, + ) + map[BodyPart.RIGHT_MIDDLE_TOE] = map.getValue(BodyPart.RIGHT_MIDDLE_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(23f, 53f, 28f), + isActive = true, + ) + } + + val state = SkeletonState( + boneInputs = inputs, + skeletonHeight = 1.7f, + paused = false, + ) + + val newInputs = processor.process(state) + + val leftRingToeIsSameRotationAsLeftMiddleToe = + newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_MIDDLE_TOE]?.rawRotation + val leftLittleToeIsSameRotationAsLeftRingToe = + newInputs.boneInputs[BodyPart.LEFT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation + + val rightRingToeIsSameRotationAsRightMiddleToe = + newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_MIDDLE_TOE]?.rawRotation + val rightLittleToeIsSameRotationAsRightRingToe = + newInputs.boneInputs[BodyPart.RIGHT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation + + assertTrue(leftRingToeIsSameRotationAsLeftMiddleToe && leftLittleToeIsSameRotationAsLeftRingToe) + assertTrue(rightRingToeIsSameRotationAsRightMiddleToe && rightLittleToeIsSameRotationAsRightRingToe) + } + + @Test + fun `test missing little toe tracker`() { + val processor = ToeDirectLinkProcessor() + val inputs = DEFAULT_SKELETON_STATE.boneInputs.mutate { map -> + map[BodyPart.LEFT_FOOT] = map.getValue(BodyPart.LEFT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(10f, 40f, 15f), + isActive = true, + ) + map[BodyPart.LEFT_BIG_TOE] = map.getValue(BodyPart.LEFT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(11f, 41f, 16f), + isActive = true, + ) + map[BodyPart.LEFT_INDEX_TOE] = map.getValue(BodyPart.LEFT_INDEX_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(12f, 42f, 17f), + isActive = true, + ) + map[BodyPart.LEFT_MIDDLE_TOE] = map.getValue(BodyPart.LEFT_MIDDLE_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(15f, 45f, 19f), + isActive = true, + ) + map[BodyPart.LEFT_RING_TOE] = map.getValue(BodyPart.LEFT_RING_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(13f, 43f, 18f), + isActive = true, + ) + + map[BodyPart.RIGHT_FOOT] = map.getValue(BodyPart.RIGHT_FOOT).copy( + rawRotation = Quaternion.Companion.fromRotationVector(20f, 50f, 25f), + isActive = true, + ) + map[BodyPart.RIGHT_BIG_TOE] = map.getValue(BodyPart.RIGHT_BIG_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(21f, 51f, 26f), + isActive = true, + ) + map[BodyPart.RIGHT_INDEX_TOE] = map.getValue(BodyPart.RIGHT_INDEX_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(22f, 52f, 27f), + isActive = true, + ) + map[BodyPart.RIGHT_MIDDLE_TOE] = map.getValue(BodyPart.RIGHT_MIDDLE_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(23f, 53f, 28f), + isActive = true, + ) + map[BodyPart.RIGHT_RING_TOE] = map.getValue(BodyPart.RIGHT_RING_TOE).copy( + rawRotation = Quaternion.Companion.fromRotationVector(24f, 54f, 29f), + isActive = true, + ) + } + + val state = SkeletonState( + boneInputs = inputs, + skeletonHeight = 1.7f, + paused = false, + ) + + val newInputs = processor.process(state) + + val leftLittleToeIsSameRotationAsLeftRingToe = + newInputs.boneInputs[BodyPart.LEFT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.LEFT_RING_TOE]?.rawRotation + val rightLittleToeIsSameRotationAsRightRingToe = + newInputs.boneInputs[BodyPart.RIGHT_LITTLE_TOE]?.rawRotation == newInputs.boneInputs[BodyPart.RIGHT_RING_TOE]?.rawRotation + + assertTrue(leftLittleToeIsSameRotationAsLeftRingToe && rightLittleToeIsSameRotationAsRightRingToe) + } +} diff --git a/solarxr-protocol b/solarxr-protocol index 0b25dd5a15..48b858edc3 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 0b25dd5a15bcb894e71a351bb4a9fd6aa865d9ec +Subproject commit 48b858edc314968ab9f6fbdae869e3897c625de0