Files
Mike Nelson 2fcf1ba686 Lab camera snapping widget (#7400)
* camera control widget with snapping

* fresh brain, fresh tweaks

* clang
2026-05-30 05:46:43 +02:00

582 lines
17 KiB
C++

#include "globalincs/pstypes.h"
#include "graphics/2d.h"
#include "io/key.h"
#include "io/mouse.h"
#include "lab/renderer/lab_cameras.h"
#include "lab/labv2_internal.h"
LabCamera::~LabCamera() {
cam_delete(FS_camera);
}
namespace {
bool point_in_rect(int x, int y, int rect_x, int rect_y, int rect_w, int rect_h)
{
return x >= rect_x && x < rect_x + rect_w && y >= rect_y && y < rect_y + rect_h;
}
struct WidgetFaceProjection {
OrbitCamera::SnapDirection direction;
const char* label;
vec3d center_world;
vec3d normal_world;
vec3d corners_world[4];
int corners_screen_x[4] = {0, 0, 0, 0};
int corners_screen_y[4] = {0, 0, 0, 0};
int center_screen_x = 0;
int center_screen_y = 0;
float center_depth = 0.0f;
bool visible = false;
};
const char* snap_direction_label(OrbitCamera::SnapDirection direction)
{
switch (direction) {
case OrbitCamera::SnapDirection::Front:
return "Front";
case OrbitCamera::SnapDirection::Back:
return "Back";
case OrbitCamera::SnapDirection::Top:
return "Top";
case OrbitCamera::SnapDirection::Bottom:
return "Bottom";
case OrbitCamera::SnapDirection::Left:
return "Left";
case OrbitCamera::SnapDirection::Right:
return "Right";
default:
return "";
}
}
vec3d snap_direction_normal(OrbitCamera::SnapDirection direction)
{
switch (direction) {
case OrbitCamera::SnapDirection::Front:
return vm_vec_new(0.0f, 0.0f, 1.0f);
case OrbitCamera::SnapDirection::Back:
return vm_vec_new(0.0f, 0.0f, -1.0f);
case OrbitCamera::SnapDirection::Top:
return vm_vec_new(0.0f, 1.0f, 0.0f);
case OrbitCamera::SnapDirection::Bottom:
return vm_vec_new(0.0f, -1.0f, 0.0f);
case OrbitCamera::SnapDirection::Left:
return vm_vec_new(-1.0f, 0.0f, 0.0f);
case OrbitCamera::SnapDirection::Right:
return vm_vec_new(1.0f, 0.0f, 0.0f);
default:
return vmd_zero_vector;
}
}
bool point_in_convex_quad(int x, int y, const int quad_x[4], const int quad_y[4])
{
int sign = 0;
for (int i = 0; i < 4; ++i) {
const int next = (i + 1) % 4;
const int edge_x = quad_x[next] - quad_x[i];
const int edge_y = quad_y[next] - quad_y[i];
const int to_point_x = x - quad_x[i];
const int to_point_y = y - quad_y[i];
const int cross = edge_x * to_point_y - edge_y * to_point_x;
if (cross == 0) {
continue;
}
const int current_sign = (cross > 0) ? 1 : -1;
if (sign == 0) {
sign = current_sign;
} else if (sign != current_sign) {
return false;
}
}
return true;
}
void get_orbit_view_basis(float phi, float theta, vec3d& forward, vec3d& right, vec3d& up)
{
vec3d camera_offset;
camera_offset.xyz.x = sinf(phi) * cosf(theta);
camera_offset.xyz.y = cosf(phi);
camera_offset.xyz.z = sinf(phi) * sinf(theta);
vm_vec_normalize_safe(&camera_offset);
vm_vec_copy_scale(&forward, &camera_offset, -1.0f);
vec3d world_up = vmd_y_vector;
vm_vec_cross(&right, &world_up, &forward);
if (vm_vec_mag_squared(&right) <= 1e-6f) {
world_up = vmd_x_vector;
vm_vec_cross(&right, &world_up, &forward);
}
vm_vec_normalize_safe(&right);
vm_vec_cross(&up, &forward, &right);
vm_vec_normalize_safe(&up);
}
SCP_vector<WidgetFaceProjection> build_widget_faces(const vec3d& forward, const vec3d& right, const vec3d& up, int center_x, int center_y, int half_size_px)
{
const float cube_half = 1.0f;
SCP_vector<WidgetFaceProjection> faces;
faces.reserve(6);
faces.push_back({OrbitCamera::SnapDirection::Front,
"Front",
vm_vec_new(0.0f, 0.0f, cube_half),
vm_vec_new(0.0f, 0.0f, 1.0f),
{vm_vec_new(-cube_half, cube_half, cube_half),
vm_vec_new(cube_half, cube_half, cube_half),
vm_vec_new(cube_half, -cube_half, cube_half),
vm_vec_new(-cube_half, -cube_half, cube_half)}});
faces.push_back({OrbitCamera::SnapDirection::Back,
"Back",
vm_vec_new(0.0f, 0.0f, -cube_half),
vm_vec_new(0.0f, 0.0f, -1.0f),
{vm_vec_new(cube_half, cube_half, -cube_half),
vm_vec_new(-cube_half, cube_half, -cube_half),
vm_vec_new(-cube_half, -cube_half, -cube_half),
vm_vec_new(cube_half, -cube_half, -cube_half)}});
faces.push_back({OrbitCamera::SnapDirection::Top,
"Top",
vm_vec_new(0.0f, cube_half, 0.0f),
vm_vec_new(0.0f, 1.0f, 0.0f),
{vm_vec_new(-cube_half, cube_half, -cube_half),
vm_vec_new(cube_half, cube_half, -cube_half),
vm_vec_new(cube_half, cube_half, cube_half),
vm_vec_new(-cube_half, cube_half, cube_half)}});
faces.push_back({OrbitCamera::SnapDirection::Bottom,
"Bottom",
vm_vec_new(0.0f, -cube_half, 0.0f),
vm_vec_new(0.0f, -1.0f, 0.0f),
{vm_vec_new(-cube_half, -cube_half, cube_half),
vm_vec_new(cube_half, -cube_half, cube_half),
vm_vec_new(cube_half, -cube_half, -cube_half),
vm_vec_new(-cube_half, -cube_half, -cube_half)}});
faces.push_back({OrbitCamera::SnapDirection::Left,
"Left",
vm_vec_new(-cube_half, 0.0f, 0.0f),
vm_vec_new(-1.0f, 0.0f, 0.0f),
{vm_vec_new(-cube_half, cube_half, -cube_half),
vm_vec_new(-cube_half, cube_half, cube_half),
vm_vec_new(-cube_half, -cube_half, cube_half),
vm_vec_new(-cube_half, -cube_half, -cube_half)}});
faces.push_back({OrbitCamera::SnapDirection::Right,
"Right",
vm_vec_new(cube_half, 0.0f, 0.0f),
vm_vec_new(1.0f, 0.0f, 0.0f),
{vm_vec_new(cube_half, cube_half, cube_half),
vm_vec_new(cube_half, cube_half, -cube_half),
vm_vec_new(cube_half, -cube_half, -cube_half),
vm_vec_new(cube_half, -cube_half, cube_half)}});
vec3d camera_dir;
vm_vec_copy_scale(&camera_dir, &forward, -1.0f);
for (auto& face : faces) {
face.visible = vm_vec_dot(&face.normal_world, &camera_dir) > 0.0f;
vec3d center_view;
center_view.xyz.x = vm_vec_dot(&face.center_world, &right);
center_view.xyz.y = vm_vec_dot(&face.center_world, &up);
center_view.xyz.z = vm_vec_dot(&face.center_world, &camera_dir);
face.center_screen_x = center_x + fl2i(center_view.xyz.x * half_size_px);
face.center_screen_y = center_y - fl2i(center_view.xyz.y * half_size_px);
face.center_depth = center_view.xyz.z;
for (int i = 0; i < 4; ++i) {
vec3d view;
view.xyz.x = vm_vec_dot(&face.corners_world[i], &right);
view.xyz.y = vm_vec_dot(&face.corners_world[i], &up);
view.xyz.z = vm_vec_dot(&face.corners_world[i], &camera_dir);
face.corners_screen_x[i] = center_x + fl2i(view.xyz.x * half_size_px);
face.corners_screen_y[i] = center_y - fl2i(view.xyz.y * half_size_px);
}
}
std::sort(faces.begin(), faces.end(), [](const WidgetFaceProjection& a, const WidgetFaceProjection& b) {
return a.center_depth < b.center_depth;
});
return faces;
}
OrbitCamera::SnapDirection pick_direction_for_axis(const vec3d& axis, const SCP_vector<OrbitCamera::SnapDirection>& excluded)
{
const std::array<OrbitCamera::SnapDirection, 6> all_dirs = {OrbitCamera::SnapDirection::Front,
OrbitCamera::SnapDirection::Back,
OrbitCamera::SnapDirection::Top,
OrbitCamera::SnapDirection::Bottom,
OrbitCamera::SnapDirection::Left,
OrbitCamera::SnapDirection::Right};
float best_dot = -FLT_MAX;
auto best_dir = OrbitCamera::SnapDirection::Front;
for (auto dir : all_dirs) {
if (std::find(excluded.begin(), excluded.end(), dir) != excluded.end()) {
continue;
}
const auto normal = snap_direction_normal(dir);
const float dir_dot = vm_vec_dot(&normal, &axis);
if (dir_dot > best_dot) {
best_dot = dir_dot;
best_dir = dir;
}
}
return best_dir;
}
struct AdjacentLabel {
OrbitCamera::SnapDirection direction;
const char* label;
int x = 0;
int y = 0;
int w = 0;
int h = 0;
};
std::array<AdjacentLabel, 4> build_adjacent_labels(const vec3d& forward, const vec3d& right, const vec3d& up, int center_x, int center_y, int cube_half_px)
{
vec3d camera_dir;
vm_vec_copy_scale(&camera_dir, &forward, -1.0f);
const auto primary = pick_direction_for_axis(camera_dir, {});
const auto opposite = pick_direction_for_axis(vm_vec_new(-camera_dir.xyz.x, -camera_dir.xyz.y, -camera_dir.xyz.z), {primary});
const auto up_dir = pick_direction_for_axis(up, {primary, opposite});
const auto down_dir = pick_direction_for_axis(vm_vec_new(-up.xyz.x, -up.xyz.y, -up.xyz.z), {primary, opposite, up_dir});
const auto right_dir = pick_direction_for_axis(right, {primary, opposite, up_dir, down_dir});
const auto left_dir =
pick_direction_for_axis(vm_vec_new(-right.xyz.x, -right.xyz.y, -right.xyz.z), {primary, opposite, up_dir, down_dir, right_dir});
static constexpr int padding_x = 5;
static constexpr int padding_y = 2;
static constexpr int margin = 6;
std::array<AdjacentLabel, 4> labels = {{
{up_dir, snap_direction_label(up_dir)},
{down_dir, snap_direction_label(down_dir)},
{left_dir, snap_direction_label(left_dir)},
{right_dir, snap_direction_label(right_dir)},
}};
for (auto& label : labels) {
int text_w = 0;
int text_h = 0;
gr_get_string_size(&text_w, &text_h, label.label);
label.w = text_w + (padding_x * 2);
label.h = text_h + (padding_y * 2);
}
labels[0].x = center_x - (labels[0].w / 2);
labels[0].y = center_y - cube_half_px - margin - labels[0].h;
labels[1].x = center_x - (labels[1].w / 2);
labels[1].y = center_y + cube_half_px + margin;
labels[2].x = center_x - cube_half_px - margin - labels[2].w;
labels[2].y = center_y - (labels[2].h / 2);
labels[3].x = center_x + cube_half_px + margin;
labels[3].y = center_y - (labels[3].h / 2);
return labels;
}
}
void OrbitCamera::handleInput(
int dx, int dy, int dz, bool, bool lmbPressed, bool rmbDown, int modifierKeys, int mouseX, int mouseY) {
if (getLabManager()->Renderer->getShowOrientationWidget() && lmbPressed && handleOrientationWidgetClick(mouseX, mouseY)) {
return;
}
if (dx == 0 && dy == 0 && dz == 0)
return;
if (dz > 0) {
for (int i = 0; i < dz; ++i) {
distance *= 0.9f;
}
} else if (dz < 0) {
for (int i = 0; i < -dz; ++i) {
distance *= 1.1f;
}
}
CLAMP(distance, 1.0f, 10000000.0f);
if (rmbDown) {
if (modifierKeys & KEY_SHIFTED) {
const float pan_factor = distance / 500.0f;
vec3d camera_offset;
camera_offset.xyz.x = sinf(phi) * cosf(theta);
camera_offset.xyz.y = cosf(phi);
camera_offset.xyz.z = sinf(phi) * sinf(theta);
vec3d view_forward;
vm_vec_copy_scale(&view_forward, &camera_offset, -1.0f);
vec3d world_up = vmd_y_vector;
vec3d view_right;
vm_vec_cross(&view_right, &world_up, &view_forward);
if (vm_vec_mag_squared(&view_right) <= 1e-6f) {
world_up = vmd_x_vector;
vm_vec_cross(&view_right, &world_up, &view_forward);
}
vm_vec_normalize_safe(&view_right);
vec3d view_up;
vm_vec_cross(&view_up, &view_forward, &view_right);
vm_vec_normalize_safe(&view_up);
vm_vec_scale_add2(&pan_offset, &view_right, -dx * pan_factor);
vm_vec_scale_add2(&pan_offset, &view_up, dy * pan_factor);
} else {
theta -= dx / 100.0f;
phi -= dy / 100.0f;
CLAMP(phi, 0.01f, PI - 0.01f);
}
}
updateCamera();
}
OrbitCamera::WidgetLayout OrbitCamera::getWidgetLayout()
{
const int size = WIDGET_CUBE_HALF_SIZE * 4;
const int left = gr_screen.center_offset_x + gr_screen.center_w - size - WIDGET_MARGIN;
const int top = gr_screen.center_offset_y + WIDGET_MARGIN;
return {size, left, top, left + size / 2, top + size / 2, WIDGET_CUBE_HALF_SIZE};
}
bool OrbitCamera::handleOrientationWidgetClick(int mouseX, int mouseY)
{
const auto layout = getWidgetLayout();
vec3d forward, right, up;
get_orbit_view_basis(phi, theta, forward, right, up);
const auto faces = build_widget_faces(forward, right, up, layout.center_x, layout.center_y, layout.cube_half);
for (auto it = faces.rbegin(); it != faces.rend(); ++it) {
if (!it->visible) {
continue;
}
if (point_in_convex_quad(mouseX, mouseY, it->corners_screen_x, it->corners_screen_y)) {
snapToDirection(it->direction);
return true;
}
}
const auto adjacent_labels = build_adjacent_labels(forward, right, up, layout.center_x, layout.center_y, layout.cube_half);
return std::any_of(adjacent_labels.begin(), adjacent_labels.end(), [&](const AdjacentLabel& label) {
if (point_in_rect(mouseX, mouseY, label.x, label.y, label.w, label.h)) {
snapToDirection(label.direction);
return true;
}
return false;
});
}
bool OrbitCamera::isOverlayHit(int mouseX, int mouseY) const
{
const auto layout = getWidgetLayout();
if (point_in_rect(mouseX, mouseY, layout.left, layout.top, layout.size, layout.size)) {
return true;
}
vec3d forward, right, up;
get_orbit_view_basis(phi, theta, forward, right, up);
const auto adjacent_labels = build_adjacent_labels(forward, right, up, layout.center_x, layout.center_y, layout.cube_half);
return std::any_of(adjacent_labels.begin(), adjacent_labels.end(), [&](const AdjacentLabel& label) {
return point_in_rect(mouseX, mouseY, label.x, label.y, label.w, label.h);
});
}
void OrbitCamera::snapToDirection(SnapDirection direction)
{
static constexpr float POLE_EPSILON = 0.01f;
pan_offset = vmd_zero_vector;
distance = getObjectFitDistance();
switch (direction) {
case SnapDirection::Front:
phi = PI_2;
theta = PI_2;
break;
case SnapDirection::Back:
phi = PI_2;
theta = -PI_2;
break;
case SnapDirection::Top:
phi = POLE_EPSILON;
break;
case SnapDirection::Bottom:
phi = PI - POLE_EPSILON;
break;
case SnapDirection::Left:
phi = PI_2;
theta = PI;
break;
case SnapDirection::Right:
phi = PI_2;
theta = 0.0f;
break;
}
updateCamera();
}
float OrbitCamera::getObjectFitDistance()
{
static constexpr float distance_multiplier = 1.6f;
float fit_distance = DEFAULT_DISTANCE;
if (getLabManager()->CurrentObject != -1) {
object* obj = &Objects[getLabManager()->CurrentObject];
// Ships and Missiles use the object radius to get a camera distance
fit_distance = obj->radius * distance_multiplier;
// Beams use the muzzle radius
if (obj->type == OBJ_BEAM) {
const int wip_idx = Beams[obj->instance].weapon_info_index;
if (wip_idx >= 0) {
fit_distance = Weapon_info[wip_idx].b_info.beam_muzzle_radius * distance_multiplier;
}
// Lasers use the laser length
} else if (obj->type == OBJ_WEAPON) {
const int wip_idx = Weapons[obj->instance].weapon_info_index;
if (wip_idx >= 0 && Weapon_info[wip_idx].render_type == WRT_LASER) {
fit_distance = Weapon_info[wip_idx].laser_length * distance_multiplier;
}
}
}
return fit_distance;
}
void OrbitCamera::resetView()
{
phi = DEFAULT_PHI;
theta = DEFAULT_THETA;
distance = DEFAULT_DISTANCE;
pan_offset = vmd_zero_vector;
displayedObjectChanged();
}
void OrbitCamera::displayedObjectChanged() {
// Reset camera panning
pan_offset = vmd_zero_vector;
distance = getObjectFitDistance();
updateCamera();
}
void OrbitCamera::updateCamera() {
auto cam = FS_camera.getCamera();
vec3d new_position;
new_position.xyz.x = sinf(phi) * cosf(theta);
new_position.xyz.y = cosf(phi);
new_position.xyz.z = sinf(phi) * sinf(theta);
vm_vec_scale(&new_position, distance);
vec3d target = vmd_zero_vector;
if (getLabManager()->CurrentObject != -1) {
object* obj = &Objects[getLabManager()->CurrentObject];
target = obj->pos;
if (obj->type == OBJ_WEAPON) {
const int wip_idx = Weapons[obj->instance].weapon_info_index;
if (wip_idx >= 0 && Weapon_info[wip_idx].render_type == WRT_LASER) {
// Offset target by half the laser length forward along the facing
vec3d fwd;
vm_vec_copy_normalize(&fwd, &obj->orient.vec.fvec);
vm_vec_scale_add2(&target, &fwd, Weapon_info[wip_idx].laser_length * 0.5f);
}
}
}
vm_vec_add2(&target, &pan_offset);
vm_vec_add2(&new_position, &target);
cam->set_position(&new_position);
// If these are the same then that's not great so do nothing and use the last facing value
if (!vm_vec_same(&new_position, &target)) {
cam->set_rotation_facing(&target);
}
}
void OrbitCamera::renderOverlay() const
{
const auto layout = getWidgetLayout();
color background;
gr_init_alphacolor(&background, 24, 24, 24, 96);
gr_set_color_fast(&background);
gr_rect(layout.left, layout.top, layout.size, layout.size, GR_RESIZE_NONE);
int mouse_x = 0;
int mouse_y = 0;
mouse_get_pos(&mouse_x, &mouse_y);
vec3d forward, right, up;
get_orbit_view_basis(phi, theta, forward, right, up);
const auto faces = build_widget_faces(forward, right, up, layout.center_x, layout.center_y, layout.cube_half);
for (const auto& face : faces) {
if (!face.visible) {
continue;
}
const bool hovered = point_in_convex_quad(mouse_x, mouse_y, face.corners_screen_x, face.corners_screen_y);
color edge_color;
gr_init_alphacolor(&edge_color, hovered ? 255 : 210, hovered ? 255 : 210, hovered ? 255 : 210, hovered ? 255 : 170);
gr_set_color_fast(&edge_color);
for (int i = 0; i < 4; ++i) {
const int next = (i + 1) % 4;
gr_line(face.corners_screen_x[i], face.corners_screen_y[i], face.corners_screen_x[next], face.corners_screen_y[next], GR_RESIZE_NONE);
}
int text_w = 0;
int text_h = 0;
gr_get_string_size(&text_w, &text_h, face.label);
gr_set_color_fast(hovered ? &Color_white : &Color_grey);
gr_string(face.center_screen_x - (text_w / 2), face.center_screen_y - (text_h / 2), face.label, GR_RESIZE_NONE);
}
const auto adjacent_labels = build_adjacent_labels(forward, right, up, layout.center_x, layout.center_y, layout.cube_half);
for (const auto& label : adjacent_labels) {
const bool hovered = point_in_rect(mouse_x, mouse_y, label.x, label.y, label.w, label.h);
color label_bg;
gr_init_alphacolor(&label_bg, 24, 24, 24, hovered ? 180 : 120);
gr_set_color_fast(&label_bg);
gr_rect(label.x, label.y, label.w, label.h, GR_RESIZE_NONE);
gr_set_color_fast(hovered ? &Color_white : &Color_silver);
gr_string(label.x + 5, label.y + 2, label.label, GR_RESIZE_NONE);
}
const char* subtitle = "Camera orientation";
int subtitle_w = 0;
int subtitle_h = 0;
gr_get_string_size(&subtitle_w, &subtitle_h, subtitle);
gr_set_color_fast(&Color_silver);
gr_string(layout.center_x - (subtitle_w / 2), layout.top + layout.size + 8, subtitle, GR_RESIZE_NONE);
}