diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl
index 6c33547edc..12485399fc 100644
--- a/gui/public/i18n/en/translation.ftl
+++ b/gui/public/i18n/en/translation.ftl
@@ -185,6 +185,7 @@ widget-imu_visualizer-rotation_raw = Raw rotation
widget-imu_visualizer-rotation_preview = Preview rotation
widget-imu_visualizer-acceleration = Acceleration
widget-imu_visualizer-position = Position
+widget-imu_visualizer-stay_aligned = Stay Aligned
## Widget: Skeleton Visualizer
widget-skeleton_visualizer-preview = Skeleton preview
@@ -209,6 +210,7 @@ tracker-table-column-temperature = Temp. °C
tracker-table-column-linear-acceleration = Accel. X/Y/Z
tracker-table-column-rotation = Rotation X/Y/Z
tracker-table-column-position = Position X/Y/Z
+tracker-table-column-stay_aligned = Stay Aligned
tracker-table-column-url = URL
## Tracker rotation
@@ -338,6 +340,7 @@ mounting_selection_menu-close = Close
settings-sidebar-title = Settings
settings-sidebar-general = General
settings-sidebar-tracker_mechanics = Tracker mechanics
+settings-sidebar-stay_aligned = Stay Aligned
settings-sidebar-fk_settings = Tracking settings
settings-sidebar-gesture_control = Gesture control
settings-sidebar-interface = Interface
@@ -428,6 +431,23 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description =
Can be disabled per tracker in the tracker's settings. Please don't shutdown any of the trackers while toggling this!
settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Use magnetometer on trackers
+settings-general-stay_aligned = Stay Aligned
+settings-general-stay_aligned-description = Keeps your trackers aligned by slowly adjusting the yaw of your trackers.
+settings-general-stay_aligned-warnings-drift_compensation = ⚠ Please disable "Drift Compensation". Stay Aligned and Drift Compensation try to solve the same problem and only one should be enabled.
+settings-general-stay_aligned-enabled-label = Enabled
+settings-general-stay_aligned-amount-label = Maximum yaw adjustment rate
+settings-general-stay_aligned-amount-description = Pick a rate depending on how good your IMU is: 0.1 deg/s for ICM45686, LSM6DSV or BNO085; 0.2 deg/s for LSM6DSR or BMI270; and 0.4 deg/s for BMI160.
+settings-general-stay_aligned-relaxed_body_angles-label = Relaxed Body Angles
+settings-general-stay_aligned-relaxed_body_angles-description = Stay Aligned needs to know your pose when you are relaxed. These angles describe how much you twist your legs and feet outwards. Do a yaw reset, then stand in a relaxed position, and press "Auto detect angles". Repeat while relaxing in a chair, and lying on your back.
+settings-general-stay_aligned-relaxed_body_angles-standing-label = Standing
+settings-general-stay_aligned-relaxed_body_angles-sitting-label = Sitting in chair
+settings-general-stay_aligned-relaxed_body_angles-lying_on_back-label = Lying on back
+settings-general-stay_aligned-relaxed_body_angles-upper_leg_angle = Upper leg
+settings-general-stay_aligned-relaxed_body_angles-lower_leg_angle = Lower leg
+settings-general-stay_aligned-relaxed_body_angles-foot_angle = Foot
+settings-general-stay_aligned-relaxed_body_angles-auto_detect = Auto detect angles
+settings-general-stay_aligned-relaxed_body_angles-reset = Reset angles
+
## FK/Tracking settings
settings-general-fk_settings = Tracking settings
diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/src/components/settings/SettingsSidebar.tsx
index ad6ddb29b6..d84e398eb3 100644
--- a/gui/src/components/settings/SettingsSidebar.tsx
+++ b/gui/src/components/settings/SettingsSidebar.tsx
@@ -58,6 +58,9 @@ export function SettingsSidebar() {
{l10n.getString('settings-sidebar-tracker_mechanics')}
+
+ {l10n.getString('settings-general-stay_aligned')}
+
{l10n.getString('settings-sidebar-fk_settings')}
diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx
index 3946b551fc..4a4dd693ec 100644
--- a/gui/src/components/settings/pages/GeneralSettings.tsx
+++ b/gui/src/components/settings/pages/GeneralSettings.tsx
@@ -16,6 +16,7 @@ import {
SettingsResponseT,
SteamVRTrackersSettingT,
TapDetectionSettingsT,
+ YawCorrectionSettingsT,
} from 'solarxr-protocol';
import { useConfig } from '@/hooks/config';
import { useWebsocketAPI } from '@/hooks/websocket-api';
@@ -33,8 +34,9 @@ import {
import { HandsWarningModal } from '@/components/settings/HandsWarningModal';
import { MagnetometerToggleSetting } from './MagnetometerToggleSetting';
import { DriftCompensationModal } from '@/components/settings/DriftCompensationModal';
+import { StayAlignedSettings } from './components/StayAlignedSettings';
-interface SettingsForm {
+export interface SettingsForm {
trackers: {
waist: boolean;
chest: boolean;
@@ -104,6 +106,18 @@ interface SettingsForm {
saveMountingReset: boolean;
resetHmdPitch: boolean;
};
+ yawCorrectionSettings: {
+ enabled: boolean;
+ amountInDegPerSec: number;
+ standingUpperLegAngle: number;
+ standingLowerLegAngle: number;
+ standingFootAngle: number;
+ sittingUpperLegAngle: number;
+ sittingLowerLegAngle: number;
+ sittingFootAngle: number;
+ lyingOnBackUpperLegAngle: number;
+ lyingOnBackLowerLegAngle: number;
+ };
}
const defaultValues: SettingsForm = {
@@ -171,6 +185,18 @@ const defaultValues: SettingsForm = {
saveMountingReset: false,
resetHmdPitch: false,
},
+ yawCorrectionSettings: {
+ enabled: true,
+ amountInDegPerSec: 0.2,
+ standingUpperLegAngle: 0.0,
+ standingLowerLegAngle: 0.0,
+ standingFootAngle: 0.0,
+ sittingUpperLegAngle: 0.0,
+ sittingLowerLegAngle: 0.0,
+ sittingFootAngle: 0.0,
+ lyingOnBackUpperLegAngle: 0.0,
+ lyingOnBackLowerLegAngle: 0.0,
+ },
};
export function GeneralSettings() {
@@ -300,6 +326,28 @@ export function GeneralSettings() {
driftCompensation.maxResets = values.driftCompensation.maxResets;
settings.driftCompensation = driftCompensation;
+ const yawCorrectionSettings = new YawCorrectionSettingsT();
+ yawCorrectionSettings.enabled = values.yawCorrectionSettings.enabled;
+ yawCorrectionSettings.amountInDegPerSec =
+ values.yawCorrectionSettings.amountInDegPerSec;
+ yawCorrectionSettings.standingUpperLegAngle =
+ values.yawCorrectionSettings.standingUpperLegAngle;
+ yawCorrectionSettings.standingLowerLegAngle =
+ values.yawCorrectionSettings.standingLowerLegAngle;
+ yawCorrectionSettings.standingFootAngle =
+ values.yawCorrectionSettings.standingFootAngle;
+ yawCorrectionSettings.sittingUpperLegAngle =
+ values.yawCorrectionSettings.sittingUpperLegAngle;
+ yawCorrectionSettings.sittingLowerLegAngle =
+ values.yawCorrectionSettings.sittingLowerLegAngle;
+ yawCorrectionSettings.sittingFootAngle =
+ values.yawCorrectionSettings.sittingFootAngle;
+ yawCorrectionSettings.lyingOnBackUpperLegAngle =
+ values.yawCorrectionSettings.lyingOnBackUpperLegAngle;
+ yawCorrectionSettings.lyingOnBackLowerLegAngle =
+ values.yawCorrectionSettings.lyingOnBackLowerLegAngle;
+ settings.yawCorrectionSettings = yawCorrectionSettings;
+
if (values.resetsSettings) {
const resetsSettings = new ResetsSettingsT();
resetsSettings.resetMountingFeet =
@@ -423,6 +471,10 @@ export function GeneralSettings() {
formData.resetsSettings = settings.resetsSettings;
}
+ if (settings.yawCorrectionSettings) {
+ formData.yawCorrectionSettings = settings.yawCorrectionSettings;
+ }
+
reset({ ...getValues(), ...formData });
});
@@ -824,6 +876,11 @@ export function GeneralSettings() {
/>
>
+
}
id="fksettings"
diff --git a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx
new file mode 100644
index 0000000000..e9c117bda6
--- /dev/null
+++ b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx
@@ -0,0 +1,381 @@
+import { FlatDeviceTracker } from '@/hooks/app';
+import { normalizeAngleAroundZero, RAD_TO_DEG } from '@/maths/angle';
+import { QuaternionFromQuatT } from '@/maths/quaternion';
+import { Control, FieldPath, UseFormSetValue } from 'react-hook-form';
+import { BodyPart } from 'solarxr-protocol';
+import { SettingsForm } from '@/components/settings/pages/GeneralSettings';
+import { Button } from '@/components/commons/Button';
+import { CheckBox } from '@/components/commons/Checkbox';
+import { WrenchIcon } from '@/components/commons/icon/WrenchIcons';
+import { NumberSelector } from '@/components/commons/NumberSelector';
+import { Typography } from '@/components/commons/Typography';
+import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout';
+import { useLocalization } from '@fluent/react';
+import { useTrackers } from '@/hooks/tracker';
+import { useLocaleConfig } from '@/i18n/config';
+import { Euler } from 'three';
+
+export function StayAlignedSettings({
+ getValues,
+ setValue,
+ control,
+}: {
+ getValues: () => SettingsForm;
+ setValue: UseFormSetValue;
+ control: Control;
+}) {
+ const { l10n } = useLocalization();
+ const { currentLocales } = useLocaleConfig();
+ const degreePerSecFormat = new Intl.NumberFormat(currentLocales, {
+ style: 'unit',
+ unit: 'degree-per-second',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+ const degreeFormat = new Intl.NumberFormat(currentLocales, {
+ style: 'unit',
+ unit: 'degree',
+ maximumFractionDigits: 0,
+ });
+
+ const { useConnectedIMUTrackers } = useTrackers();
+ const trackers = useConnectedIMUTrackers();
+
+ const values = getValues();
+
+ const yawBetweenInDeg = (
+ leftTracker: FlatDeviceTracker,
+ rightTracker: FlatDeviceTracker
+ ) => {
+ const leftTrackerYaw = new Euler().setFromQuaternion(
+ QuaternionFromQuatT(leftTracker.tracker.rotationReferenceAdjusted),
+ 'YZX'
+ ).y;
+ const rightTrackerYaw = new Euler().setFromQuaternion(
+ QuaternionFromQuatT(rightTracker.tracker.rotationReferenceAdjusted),
+ 'YZX'
+ ).y;
+ const yawDelta = normalizeAngleAroundZero(leftTrackerYaw - rightTrackerYaw);
+ return yawDelta * RAD_TO_DEG;
+ };
+
+ function findTracker(bodyPart: BodyPart): FlatDeviceTracker | undefined {
+ return trackers.find((t) => t.tracker.info?.bodyPart === bodyPart);
+ }
+
+ const detectAngles = (
+ upperLegKey: FieldPath,
+ lowerLegKey: FieldPath,
+ footKey?: FieldPath
+ ) => {
+ const leftUpperLegTracker = findTracker(BodyPart.LEFT_UPPER_LEG);
+ const rightUpperLegTracker = findTracker(BodyPart.RIGHT_UPPER_LEG);
+ if (leftUpperLegTracker && rightUpperLegTracker) {
+ const upperLegToBodyAngleInDeg =
+ yawBetweenInDeg(leftUpperLegTracker, rightUpperLegTracker) / 2.0;
+ setValue(upperLegKey, Math.round(upperLegToBodyAngleInDeg));
+ }
+
+ const leftLowerLegTracker = findTracker(BodyPart.LEFT_LOWER_LEG);
+ const rightLowerLegTracker = findTracker(BodyPart.RIGHT_LOWER_LEG);
+ if (leftLowerLegTracker && rightLowerLegTracker) {
+ const footToBodyAngleInDeg =
+ yawBetweenInDeg(leftLowerLegTracker, rightLowerLegTracker) / 2.0;
+ setValue(lowerLegKey, Math.round(footToBodyAngleInDeg));
+ }
+
+ if (footKey) {
+ const leftFootTracker = findTracker(BodyPart.LEFT_FOOT);
+ const rightFootTracker = findTracker(BodyPart.RIGHT_FOOT);
+ if (leftFootTracker && rightFootTracker) {
+ const footToBodyAngleInDeg =
+ yawBetweenInDeg(leftFootTracker, rightFootTracker) / 2.0;
+ setValue(footKey, Math.round(footToBodyAngleInDeg));
+ }
+ }
+ };
+
+ const resetAngles = (
+ upperLegKey: FieldPath,
+ lowerLegKey: FieldPath,
+ footKey?: FieldPath
+ ) => {
+ setValue(upperLegKey, 0.0);
+ setValue(lowerLegKey, 0.0);
+ if (footKey) {
+ setValue(footKey, 0.0);
+ }
+ };
+
+ return (
+ } id="stayaligned">
+
+ {l10n.getString('settings-general-stay_aligned')}
+
+
+ {l10n
+ .getString('settings-general-stay_aligned-description')
+ .split('\n')
+ .map((line, i) => (
+
+ {line}
+
+ ))}
+ {values.yawCorrectionSettings.enabled && (
+ <>
+ {!!values.driftCompensation.enabled && (
+
+ {l10n.getString(
+ 'settings-general-stay_aligned-warnings-drift_compensation'
+ )}
+
+ )}
+ >
+ )}
+
+
+
+
+
+ {l10n.getString('settings-general-stay_aligned-amount-label')}
+
+
+ {l10n.getString('settings-general-stay_aligned-amount-description')}
+
+ degreePerSecFormat.format(value)}
+ min={0.02}
+ max={2.0}
+ step={0.02}
+ />
+
+
+
+ {l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-label'
+ )}
+
+
+ {l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-description'
+ )}
+
+
+
+
+ {l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-standing-label'
+ )}
+
+
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-upper_leg_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-lower_leg_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-foot_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+
+
+
+
+
+ {l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-sitting-label'
+ )}
+
+
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-upper_leg_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-lower_leg_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-foot_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+
+
+
+
+
+ {l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-lying_on_back-label'
+ )}
+
+
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-upper_leg_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+ `${l10n.getString(
+ 'settings-general-stay_aligned-relaxed_body_angles-lower_leg_angle'
+ )}: ${degreeFormat.format(value)}`
+ }
+ min={-90.0}
+ max={90.0}
+ step={1.0}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/gui/src/components/tracker/StayAlignedInfo.tsx b/gui/src/components/tracker/StayAlignedInfo.tsx
new file mode 100644
index 0000000000..9092e5f35d
--- /dev/null
+++ b/gui/src/components/tracker/StayAlignedInfo.tsx
@@ -0,0 +1,50 @@
+import { Typography } from '@/components/commons/Typography';
+import { useLocaleConfig } from '@/i18n/config';
+import { angleIsNearZero } from '@/maths/angle';
+import { TrackerDataT } from 'solarxr-protocol';
+
+export function StayAlignedInfo({
+ color,
+ tracker,
+}: {
+ color: 'primary' | 'secondary';
+ tracker: TrackerDataT;
+}) {
+ const { currentLocales } = useLocaleConfig();
+ const degreeFormat = new Intl.NumberFormat(currentLocales, {
+ style: 'unit',
+ unit: 'degree',
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+ const errorFormat = new Intl.NumberFormat(currentLocales, {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ });
+
+ const locked = tracker.stayAlignedLocked ? '🔒' : '';
+
+ const delta = `Δ=${degreeFormat.format(tracker.stayAlignedYawCorrectionInDeg)}`;
+
+ const errors = [];
+ const maxErrorToShow = 0.1;
+ if (!angleIsNearZero(tracker.stayAlignedLockedErrorInDeg, maxErrorToShow)) {
+ errors.push(`L=${errorFormat.format(tracker.stayAlignedLockedErrorInDeg)}`);
+ }
+ if (!angleIsNearZero(tracker.stayAlignedCenterErrorInDeg, maxErrorToShow)) {
+ errors.push(`C=${errorFormat.format(tracker.stayAlignedCenterErrorInDeg)}`);
+ }
+ if (!angleIsNearZero(tracker.stayAlignedNeighborErrorInDeg, maxErrorToShow)) {
+ errors.push(
+ `N=${errorFormat.format(tracker.stayAlignedNeighborErrorInDeg)}`
+ );
+ }
+
+ const error = errors.length > 0 ? `(${errors.join(', ')})` : '';
+
+ return (
+
+ {locked} {delta} {error}
+
+ );
+}
diff --git a/gui/src/components/tracker/TrackersTable.tsx b/gui/src/components/tracker/TrackersTable.tsx
index cdba255964..f36cbcb236 100644
--- a/gui/src/components/tracker/TrackersTable.tsx
+++ b/gui/src/components/tracker/TrackersTable.tsx
@@ -17,6 +17,7 @@ import { TrackerBattery } from './TrackerBattery';
import { TrackerStatus } from './TrackerStatus';
import { TrackerWifi } from './TrackerWifi';
import { trackerStatusRelated, useStatusContext } from '@/hooks/status-system';
+import { StayAlignedInfo } from './StayAlignedInfo';
enum DisplayColumn {
NAME,
@@ -28,6 +29,7 @@ enum DisplayColumn {
TEMPERATURE,
LINEAR_ACCELERATION,
POSITION,
+ STAY_ALIGNED,
URL,
}
@@ -41,6 +43,7 @@ const displayColumns: { [k: string]: boolean } = {
[DisplayColumn.TEMPERATURE]: true,
[DisplayColumn.LINEAR_ACCELERATION]: true,
[DisplayColumn.POSITION]: true,
+ [DisplayColumn.STAY_ALIGNED]: true,
[DisplayColumn.URL]: true,
};
@@ -196,6 +199,7 @@ export function TrackersTable({
displayColumns[DisplayColumn.TEMPERATURE] = hasTemperature || false;
displayColumns[DisplayColumn.POSITION] = moreInfo || false;
displayColumns[DisplayColumn.LINEAR_ACCELERATION] = moreInfo || false;
+ displayColumns[DisplayColumn.STAY_ALIGNED] = moreInfo || false;
displayColumns[DisplayColumn.URL] = moreInfo || false;
const displayColumnsKeys = Object.keys(displayColumns).filter(
(k) => displayColumns[k]
@@ -362,6 +366,15 @@ export function TrackersTable({
),
})}
+ {column({
+ id: DisplayColumn.STAY_ALIGNED,
+ label: l10n.getString('tracker-table-column-stay_aligned'),
+ labelClassName: 'w-36',
+ row: ({ tracker }) => (
+
+ ),
+ })}
+
{column({
id: DisplayColumn.URL,
label: l10n.getString('tracker-table-column-url'),
diff --git a/gui/src/components/widgets/IMUVisualizerWidget.tsx b/gui/src/components/widgets/IMUVisualizerWidget.tsx
index 5cecbbf9fa..4a130f38cd 100644
--- a/gui/src/components/widgets/IMUVisualizerWidget.tsx
+++ b/gui/src/components/widgets/IMUVisualizerWidget.tsx
@@ -12,6 +12,7 @@ import { useLocalization } from '@fluent/react';
import { Vector3Object } from '@/maths/vector3';
import { Gltf } from '@react-three/drei';
import { ErrorBoundary } from 'react-error-boundary';
+import { StayAlignedInfo } from '@/components/tracker/StayAlignedInfo';
const groundColor = '#4444aa';
@@ -157,6 +158,15 @@ export function IMUVisualizerWidget({ tracker }: { tracker: TrackerDataT }) {
)}
+ {!!tracker.stayAlignedYawCorrectionInDeg && (
+
+
+ {l10n.getString('widget-imu_visualizer-stay_aligned')}
+
+
+
+ )}
+
{!enabled && (