@@ -4,33 +4,65 @@

#pragma once

#include <QToolButton>
#include <QWidget>

#include "InputCommon/ControllerEmu/StickGate.h"

namespace ControllerEmu
{
class Control;
class ControlGroup;
class Cursor;
class NumericSetting;
class ReshapableInput;
} // namespace ControllerEmu

class QPainter;
class QPaintEvent;
class QTimer;

class CalibrationWidget;

class MappingIndicator : public QWidget
{
public:
explicit MappingIndicator(ControllerEmu::ControlGroup* group);

void SetCalibrationWidget(CalibrationWidget* widget);

private:
void DrawCursor(ControllerEmu::Cursor& cursor);
void DrawReshapableInput(ControllerEmu::ReshapableInput& stick);
void DrawMixedTriggers();
void DrawCalibration(QPainter& p, Common::DVec2 point);

void paintEvent(QPaintEvent*) override;

ControllerEmu::ControlGroup* m_group;
bool IsCalibrating() const;
void UpdateCalibrationWidget(Common::DVec2 point);

ControllerEmu::ControlGroup* const m_group;
CalibrationWidget* m_calibration_widget{};
};

class CalibrationWidget : public QToolButton
{
public:
CalibrationWidget(ControllerEmu::ReshapableInput& input, MappingIndicator& indicator);

void Update(Common::DVec2 point);

double GetCalibrationRadiusAtAngle(double angle) const;

bool IsCalibrating() const;

private:
void StartCalibration();
void SetupActions();

QTimer* m_timer;
ControllerEmu::ReshapableInput& m_input;
MappingIndicator& m_indicator;
QAction* m_completion_action;
ControllerEmu::ReshapableInput::CalibrationData m_calibration_data;
QTimer* m_informative_timer;
};
@@ -21,6 +21,7 @@
#include "InputCommon/ControllerEmu/ControlGroup/ControlGroup.h"
#include "InputCommon/ControllerEmu/Setting/BooleanSetting.h"
#include "InputCommon/ControllerEmu/Setting/NumericSetting.h"
#include "InputCommon/ControllerEmu/StickGate.h"

MappingWidget::MappingWidget(MappingWindow* window) : m_parent(window)
{
@@ -73,10 +74,14 @@ QGroupBox* MappingWidget::CreateGroupBox(const QString& name, ControllerEmu::Con

group_box->setLayout(form_layout);

bool need_indicator = group->type == ControllerEmu::GroupType::Cursor ||
group->type == ControllerEmu::GroupType::Stick ||
group->type == ControllerEmu::GroupType::Tilt ||
group->type == ControllerEmu::GroupType::MixedTriggers;
const bool need_indicator = group->type == ControllerEmu::GroupType::Cursor ||
group->type == ControllerEmu::GroupType::Stick ||
group->type == ControllerEmu::GroupType::Tilt ||
group->type == ControllerEmu::GroupType::MixedTriggers;

const bool need_calibration = group->type == ControllerEmu::GroupType::Cursor ||
group->type == ControllerEmu::GroupType::Stick ||
group->type == ControllerEmu::GroupType::Tilt;

for (auto& control : group->controls)
{
@@ -135,7 +140,19 @@ QGroupBox* MappingWidget::CreateGroupBox(const QString& name, ControllerEmu::Con
}

if (need_indicator)
form_layout->addRow(new MappingIndicator(group));
{
auto const indicator = new MappingIndicator(group);

if (need_calibration)
{
const auto calibrate =
new CalibrationWidget(*static_cast<ControllerEmu::ReshapableInput*>(group), *indicator);

form_layout->addRow(calibrate);
}

form_layout->addRow(indicator);
}

return group_box;
}
@@ -148,6 +148,8 @@ void MappingWindow::ConnectWidgets()
connect(m_profiles_save, &QPushButton::clicked, this, &MappingWindow::OnSaveProfilePressed);
connect(m_profiles_load, &QPushButton::clicked, this, &MappingWindow::OnLoadProfilePressed);
connect(m_profiles_delete, &QPushButton::clicked, this, &MappingWindow::OnDeleteProfilePressed);
// We currently use the "Close" button as an "Accept" button so we must save on reject.
connect(this, &QDialog::rejected, [this] { emit Save(); });
}

void MappingWindow::OnDeleteProfilePressed()
@@ -30,11 +30,6 @@ AnalogStick::AnalogStick(const char* const name_, const char* const ui_name_,
controls.emplace_back(std::make_unique<Input>(Translate, named_direction));

controls.emplace_back(std::make_unique<Input>(Translate, _trans("Modifier")));

// Default input radius to that of the gate radius (no resizing)
// Default input shape to an octagon (no reshaping)
// Max deadzone to 50%
AddReshapingSettings(GetGateRadiusAtAngle(0.0), 0.0, 50);
}

AnalogStick::ReshapeData AnalogStick::GetReshapableState(bool adjusted)
@@ -32,9 +32,6 @@ Cursor::Cursor(const std::string& name_)
controls.emplace_back(std::make_unique<Input>(Translate, _trans("Hide")));
controls.emplace_back(std::make_unique<Input>(Translate, _trans("Recenter")));

// Default shape is a 1.0 square (no resizing/reshaping):
AddReshapingSettings(1.0, 0.5, 50);

numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Center"), 0.5));
numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Width"), 0.5));
numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Height"), 0.5));
@@ -57,8 +54,6 @@ Cursor::ReshapeData Cursor::GetReshapableState(bool adjusted)

ControlState Cursor::GetGateRadiusAtAngle(double ang) const
{
// TODO: Change this to 0.5 and adjust the math,
// so pointer doesn't have to be clamped to the configured width/height?
return SquareStickGate(1.0).GetRadiusAtAngle(ang);
}

@@ -29,11 +29,6 @@ Tilt::Tilt(const std::string& name_)

controls.emplace_back(std::make_unique<Input>(Translate, _trans("Modifier")));

// Set default input radius to the full 1.0 (no resizing)
// Set default input shape to a square (no reshaping)
// Max deadzone to 50%
AddReshapingSettings(1.0, 0.5, 50);

numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Angle"), 0.9, 0, 180));
}

@@ -88,4 +83,9 @@ ControlState Tilt::GetGateRadiusAtAngle(double ang) const
return SquareStickGate(max_tilt_angle).GetRadiusAtAngle(ang);
}

ControlState Tilt::GetDefaultInputRadiusAtAngle(double ang) const
{
return SquareStickGate(1.0).GetRadiusAtAngle(ang);
}

} // namespace ControllerEmu
@@ -20,7 +20,11 @@ class Tilt : public ReshapableInput
explicit Tilt(const std::string& name);

ReshapeData GetReshapableState(bool adjusted) final override;
ControlState GetGateRadiusAtAngle(double ang) const override;
ControlState GetGateRadiusAtAngle(double angle) const final override;

// Tilt is using the gate radius to adjust the tilt angle so we must provide an unadjusted value
// for the default input radius.
ControlState GetDefaultInputRadiusAtAngle(double angle) const final override;

StateData GetState();

@@ -8,23 +8,67 @@

#include "Common/Common.h"
#include "Common/MathUtil.h"
#include "Common/Matrix.h"
#include "Common/StringUtil.h"
#include "InputCommon/ControllerEmu/Control/Control.h"
#include "InputCommon/ControllerEmu/Setting/NumericSetting.h"

namespace
{
constexpr auto CALIBRATION_CONFIG_NAME = "Calibration";
constexpr auto CALIBRATION_DEFAULT_VALUE = 1.0;
constexpr auto CALIBRATION_CONFIG_SCALE = 100;

// Calculate distance to intersection of a ray with a line defined by two points.
double GetRayLineIntersection(Common::DVec2 ray, Common::DVec2 point1, Common::DVec2 point2)
{
const auto diff = point2 - point1;

const auto dot = diff.Dot({-ray.y, ray.x});
if (std::abs(dot) < 0.00001)
{
// Handle situation where both points are on top of eachother.
// This could occur if the user configures a single calibration value
// or when updating calibration.
return point1.Length();
}

return diff.Cross(-point1) / dot;
}

Common::DVec2 GetPointFromAngleAndLength(double angle, double length)
{
return Common::DVec2{std::cos(angle), std::sin(angle)} * length;
}
} // namespace

namespace ControllerEmu
{
constexpr int ReshapableInput::CALIBRATION_SAMPLE_COUNT;

std::optional<u32> StickGate::GetIdealCalibrationSampleCount() const
{
return {};
}

OctagonStickGate::OctagonStickGate(ControlState radius) : m_radius(radius)
{
}

ControlState OctagonStickGate::GetRadiusAtAngle(double ang) const
ControlState OctagonStickGate::GetRadiusAtAngle(double angle) const
{
constexpr int sides = 8;
constexpr double sum_int_angles = (sides - 2) * MathUtil::PI;
constexpr double half_int_angle = sum_int_angles / sides / 2;

ang = std::fmod(ang, MathUtil::TAU / sides);
angle = std::fmod(angle, MathUtil::TAU / sides);
// Solve ASA triangle using The Law of Sines:
return m_radius / std::sin(MathUtil::PI - ang - half_int_angle) * std::sin(half_int_angle);
return m_radius / std::sin(MathUtil::PI - angle - half_int_angle) * std::sin(half_int_angle);
}

std::optional<u32> OctagonStickGate::GetIdealCalibrationSampleCount() const
{
return 8;
}

RoundStickGate::RoundStickGate(ControlState radius) : m_radius(radius)
@@ -40,50 +84,171 @@ SquareStickGate::SquareStickGate(ControlState half_width) : m_half_width(half_wi
{
}

ControlState SquareStickGate::GetRadiusAtAngle(double ang) const
ControlState SquareStickGate::GetRadiusAtAngle(double angle) const
{
constexpr double section_ang = MathUtil::TAU / 4;
return m_half_width / std::cos(std::fmod(ang + section_ang / 2, section_ang) - section_ang / 2);
constexpr double section_angle = MathUtil::TAU / 4;
return m_half_width /
std::cos(std::fmod(angle + section_angle / 2, section_angle) - section_angle / 2);
}

std::optional<u32> SquareStickGate::GetIdealCalibrationSampleCount() const
{
// Because angle:0 points to the right we must use 8 samples for our square.
return 8;
}

ReshapableInput::ReshapableInput(std::string name, std::string ui_name, GroupType type)
: ControlGroup(std::move(name), std::move(ui_name), type)
{
numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Dead Zone"), 0, 0, 50));
}

ControlState ReshapableInput::GetDeadzoneRadiusAtAngle(double ang) const
ControlState ReshapableInput::GetDeadzoneRadiusAtAngle(double angle) const
{
return CalculateInputShapeRadiusAtAngle(ang) * numeric_settings[SETTING_DEADZONE]->GetValue();
// FYI: deadzone is scaled by input radius which allows the shape to match.
return GetInputRadiusAtAngle(angle) * numeric_settings[SETTING_DEADZONE]->GetValue();
}

ControlState ReshapableInput::GetInputRadiusAtAngle(double ang) const
ControlState ReshapableInput::GetInputRadiusAtAngle(double angle) const
{
const ControlState radius =
CalculateInputShapeRadiusAtAngle(ang) * numeric_settings[SETTING_INPUT_RADIUS]->GetValue();
// Clamp within the -1 to +1 square as input radius may be greater than 1.0:
return std::min(radius, SquareStickGate(1).GetRadiusAtAngle(ang));
// Handle the "default" state.
if (m_calibration.empty())
{
return GetDefaultInputRadiusAtAngle(angle);
}

return GetCalibrationDataRadiusAtAngle(m_calibration, angle);
}

void ReshapableInput::AddReshapingSettings(ControlState default_radius, ControlState default_shape,
int max_deadzone)
ControlState ReshapableInput::GetCalibrationDataRadiusAtAngle(const CalibrationData& data,
double angle)
{
// Allow radius greater than 1.0 for definitions of rounded squares
// This is ideal for Xbox controllers (and probably others)
numeric_settings.emplace_back(
std::make_unique<NumericSetting>(_trans("Input Radius"), default_radius, 0, 140));
numeric_settings.emplace_back(
std::make_unique<NumericSetting>(_trans("Input Shape"), default_shape, 0, 50));
numeric_settings.emplace_back(std::make_unique<NumericSetting>(_trans("Dead Zone"), 0, 0, 50));
const auto sample_pos = angle / MathUtil::TAU * data.size();
// Interpolate the radius between 2 calibration samples.
const u32 sample1_index = u32(sample_pos) % data.size();
const u32 sample2_index = (sample1_index + 1) % data.size();
const double sample1_angle = sample1_index * MathUtil::TAU / data.size();
const double sample2_angle = sample2_index * MathUtil::TAU / data.size();

return GetRayLineIntersection(GetPointFromAngleAndLength(angle, 1.0),
GetPointFromAngleAndLength(sample1_angle, data[sample1_index]),
GetPointFromAngleAndLength(sample2_angle, data[sample2_index]));
}

ControlState ReshapableInput::GetDefaultInputRadiusAtAngle(double angle) const
{
// This will normally be the same as the gate radius.
// Unless a sub-class is doing weird things with the gate radius (e.g. Tilt)
return GetGateRadiusAtAngle(angle);
}

void ReshapableInput::SetCalibrationToDefault()
{
m_calibration.clear();
}

void ReshapableInput::SetCalibrationFromGate(const StickGate& gate)
{
m_calibration.resize(gate.GetIdealCalibrationSampleCount().value_or(CALIBRATION_SAMPLE_COUNT));

u32 i = 0;
for (auto& val : m_calibration)
val = gate.GetRadiusAtAngle(MathUtil::TAU * i++ / m_calibration.size());
}

void ReshapableInput::UpdateCalibrationData(CalibrationData& data, Common::DVec2 point)
{
const auto angle_scale = MathUtil::TAU / data.size();

const u32 calibration_index =
std::lround((std::atan2(point.y, point.x) + MathUtil::TAU) / angle_scale) % data.size();
const double calibration_angle = calibration_index * angle_scale;
auto& calibration_sample = data[calibration_index];

// Update closest sample from provided x,y.
calibration_sample = std::max(calibration_sample, point.Length());

// Here we update all other samples in our calibration vector to maintain
// a convex polygon containing our new calibration point.
// This is required to properly fill in angles that cannot be gotten.
// (e.g. Keyboard input only has 8 possible angles)

// Note: Loop assumes an even sample count, which should not be a problem.
for (auto sample_offset = u32(data.size() / 2 - 1); sample_offset > 1; --sample_offset)
{
const auto update_at_offset = [&](u32 offset1, u32 offset2) {
const u32 sample1_index = (calibration_index + offset1) % data.size();
const double sample1_angle = sample1_index * angle_scale;
auto& sample1 = data[sample1_index];

const u32 sample2_index = (calibration_index + offset2) % data.size();
const double sample2_angle = sample2_index * angle_scale;
auto& sample2 = data[sample2_index];

const double intersection =
GetRayLineIntersection(GetPointFromAngleAndLength(sample2_angle, 1.0),
GetPointFromAngleAndLength(sample1_angle, sample1),
GetPointFromAngleAndLength(calibration_angle, calibration_sample));

sample2 = std::max(sample2, intersection);
};

update_at_offset(sample_offset, sample_offset - 1);
update_at_offset(u32(data.size() - sample_offset), u32(data.size() - sample_offset + 1));
}
}

const ReshapableInput::CalibrationData& ReshapableInput::GetCalibrationData() const
{
return m_calibration;
}

void ReshapableInput::SetCalibrationData(CalibrationData data)
{
m_calibration = std::move(data);
}

void ReshapableInput::LoadConfig(IniFile::Section* section, const std::string& default_device,
const std::string& base_name)
{
ControlGroup::LoadConfig(section, default_device, base_name);

const std::string group(base_name + name + '/');
std::string load_str;
section->Get(group + CALIBRATION_CONFIG_NAME, &load_str, "");
const auto load_data = SplitString(load_str, ' ');

m_calibration.assign(load_data.size(), CALIBRATION_DEFAULT_VALUE);

auto it = load_data.begin();
for (auto& sample : m_calibration)
{
if (TryParse(*(it++), &sample))
sample /= CALIBRATION_CONFIG_SCALE;
}
}

void ReshapableInput::SaveConfig(IniFile::Section* section, const std::string& default_device,
const std::string& base_name)
{
ControlGroup::SaveConfig(section, default_device, base_name);

const std::string group(base_name + name + '/');
std::vector<std::string> save_data(m_calibration.size());
std::transform(
m_calibration.begin(), m_calibration.end(), save_data.begin(),
[](ControlState val) { return StringFromFormat("%.2f", val * CALIBRATION_CONFIG_SCALE); });
section->Set(group + CALIBRATION_CONFIG_NAME, JoinStrings(save_data, " "), "");
}

ReshapableInput::ReshapeData ReshapableInput::Reshape(ControlState x, ControlState y,
ControlState modifier)
{
// TODO: make the AtAngle functions work with negative angles:
const ControlState ang = std::atan2(y, x) + MathUtil::TAU;
const ControlState angle = std::atan2(y, x) + MathUtil::TAU;

const ControlState gate_max_dist = GetGateRadiusAtAngle(ang);
const ControlState input_max_dist = GetInputRadiusAtAngle(ang);
const ControlState gate_max_dist = GetGateRadiusAtAngle(angle);
const ControlState input_max_dist = GetInputRadiusAtAngle(angle);

// If input radius is zero we apply no scaling.
// This is useful when mapping native controllers without knowing intimate radius details.
@@ -103,33 +268,15 @@ ReshapableInput::ReshapeData ReshapableInput::Reshape(ControlState x, ControlSta
}

// Apply deadzone as a percentage of the user-defined radius/shape:
const ControlState deadzone = GetDeadzoneRadiusAtAngle(ang);
const ControlState deadzone = GetDeadzoneRadiusAtAngle(angle);
dist = std::max(0.0, dist - deadzone) / (1.0 - deadzone);

// Scale to the gate shape/radius:
dist = dist *= gate_max_dist;

x = MathUtil::Clamp(std::cos(ang) * dist, -1.0, 1.0);
y = MathUtil::Clamp(std::sin(ang) * dist, -1.0, 1.0);
x = MathUtil::Clamp(std::cos(angle) * dist, -1.0, 1.0);
y = MathUtil::Clamp(std::sin(angle) * dist, -1.0, 1.0);
return {x, y};
}

ControlState ReshapableInput::CalculateInputShapeRadiusAtAngle(double ang) const
{
const auto shape = numeric_settings[SETTING_INPUT_SHAPE]->GetValue() * 4.0;

if (shape < 1.0)
{
// Between 0 and 25 return a shape between octagon and circle
const auto amt = shape;
return OctagonStickGate(1).GetRadiusAtAngle(ang) * (1 - amt) + amt;
}
else
{
// Between 25 and 50 return a shape between circle and square
const auto amt = shape - 1.0;
return (1 - amt) + SquareStickGate(1).GetRadiusAtAngle(ang) * amt;
}
}

} // namespace ControllerEmu
@@ -4,6 +4,11 @@

#pragma once

#include <optional>
#include <vector>

#include "Common/Matrix.h"

#include "InputCommon/ControlReference/ControlReference.h"
#include "InputCommon/ControllerEmu/ControlGroup/ControlGroup.h"

@@ -16,6 +21,10 @@ class StickGate
// Angle is in radians and should be non-negative
virtual ControlState GetRadiusAtAngle(double ang) const = 0;

// This is provided purely as an optimization for ReshapableInput to produce a minimal amount of
// calibration points that are saved in our config.
virtual std::optional<u32> GetIdealCalibrationSampleCount() const;

virtual ~StickGate() = default;
};

@@ -26,6 +35,7 @@ class OctagonStickGate : public StickGate
// Radius of circumscribed circle
explicit OctagonStickGate(ControlState radius);
ControlState GetRadiusAtAngle(double ang) const override final;
std::optional<u32> GetIdealCalibrationSampleCount() const override final;

private:
const ControlState m_radius;
@@ -48,6 +58,7 @@ class SquareStickGate : public StickGate
public:
explicit SquareStickGate(ControlState half_width);
ControlState GetRadiusAtAngle(double ang) const override final;
std::optional<u32> GetIdealCalibrationSampleCount() const override final;

private:
const ControlState m_half_width;
@@ -56,37 +67,47 @@ class SquareStickGate : public StickGate
class ReshapableInput : public ControlGroup
{
public:
// This is the number of samples we generate but any number could be loaded from config.
static constexpr int CALIBRATION_SAMPLE_COUNT = 32;

// Contains input radius maximums at evenly-spaced angles.
using CalibrationData = std::vector<ControlState>;

ReshapableInput(std::string name, std::string ui_name, GroupType type);

struct ReshapeData
{
ControlState x{};
ControlState y{};
};
using ReshapeData = Common::DVec2;

enum
{
SETTING_INPUT_RADIUS,
SETTING_INPUT_SHAPE,
SETTING_DEADZONE,
SETTING_COUNT,
};

// Angle is in radians and should be non-negative
ControlState GetDeadzoneRadiusAtAngle(double ang) const;
ControlState GetInputRadiusAtAngle(double ang) const;
ControlState GetDeadzoneRadiusAtAngle(double angle) const;
ControlState GetInputRadiusAtAngle(double angle) const;

virtual ControlState GetGateRadiusAtAngle(double ang) const = 0;
virtual ControlState GetGateRadiusAtAngle(double angle) const = 0;
virtual ReshapeData GetReshapableState(bool adjusted) = 0;
virtual ControlState GetDefaultInputRadiusAtAngle(double ang) const;

protected:
void AddReshapingSettings(ControlState default_radius, ControlState default_shape,
int max_deadzone);
void SetCalibrationToDefault();
void SetCalibrationFromGate(const StickGate& gate);

static void UpdateCalibrationData(CalibrationData& data, Common::DVec2 point);
static ControlState GetCalibrationDataRadiusAtAngle(const CalibrationData& data, double angle);

const CalibrationData& GetCalibrationData() const;
void SetCalibrationData(CalibrationData data);

protected:
ReshapeData Reshape(ControlState x, ControlState y, ControlState modifier = 0.0);

private:
ControlState CalculateInputShapeRadiusAtAngle(double ang) const;
void LoadConfig(IniFile::Section*, const std::string&, const std::string&) override;
void SaveConfig(IniFile::Section*, const std::string&, const std::string&) override;

CalibrationData m_calibration;
};

} // namespace ControllerEmu