Added tests for editor

main
noah metz 2026-07-27 23:47:54 -06:00
parent 4210ff1aba
commit 8252418db9
10 changed files with 443 additions and 78 deletions

@ -7,14 +7,16 @@ LDFLAGS += $(shell pkg-config --libs lua)
ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp
APP_SOURCES = src/main.c $(ENGINE_SOURCES) APP_SOURCES = src/main.c $(ENGINE_SOURCES)
EDITOR_SOURCES = src/editor.c $(ENGINE_SOURCES) EDITOR_SOURCES = src/editor_main.c src/editor.c $(ENGINE_SOURCES)
TEST_SOURCES = test/hsv.c $(ENGINE_SOURCES) TEST_SOURCES = test/hsv.c $(ENGINE_SOURCES)
TEST_EDITOR_SOURCES = test/editor.c src/editor.c $(ENGINE_SOURCES)
APP_OBJECTS = $(addsuffix .o, $(basename $(APP_SOURCES))) APP_OBJECTS = $(addsuffix .o, $(basename $(APP_SOURCES)))
EDITOR_OBJECTS = $(addsuffix .o, $(basename $(EDITOR_SOURCES))) EDITOR_OBJECTS = $(addsuffix .o, $(basename $(EDITOR_SOURCES)))
TEST_OBJECTS = $(addsuffix .o, $(basename $(TEST_SOURCES))) TEST_OBJECTS = $(addsuffix .o, $(basename $(TEST_SOURCES)))
TEST_EDITOR_OBJECTS = $(addsuffix .o, $(basename $(TEST_EDITOR_SOURCES)))
ALL_OBJECTS = $(sort $(APP_OBJECTS) $(EDITOR_OBJECTS) $(TEST_OBJECTS)) ALL_OBJECTS = $(sort $(APP_OBJECTS) $(EDITOR_OBJECTS) $(TEST_OBJECTS) $(TEST_EDITOR_OBJECTS))
DEP_FILES = $(ALL_OBJECTS:.o=.d) DEP_FILES = $(ALL_OBJECTS:.o=.d)
VERT_SPV = $(addsuffix .vert.spv, $(basename $(wildcard shader/*.vert))) VERT_SPV = $(addsuffix .vert.spv, $(basename $(wildcard shader/*.vert)))
@ -52,9 +54,13 @@ editor: $(EDITOR_OBJECTS) | $(SPV_FILES)
test/test_hsv: $(TEST_OBJECTS) test/test_hsv: $(TEST_OBJECTS)
$(CXX) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(CXX) $(CFLAGS) $(LDFLAGS) -o $@ $^
test/test_editor: $(TEST_EDITOR_OBJECTS) | $(SPV_FILES)
$(CXX) $(CFLAGS) $(LDFLAGS) -o $@ $^
.PHONY: test .PHONY: test
test: test/test_hsv test: test/test_hsv test/test_editor
./test/test_hsv ./test/test_hsv
./test/test_editor
%.o: %.cpp %.o: %.cpp
$(CXX) $(CFLAGS) -std=c++17 -Wno-nullability-completeness -Wno-unused-parameter -Wno-missing-field-initializers -Wno-unused-private-field -Wno-unused-variable -c -o $@ $< $(CXX) $(CFLAGS) -std=c++17 -Wno-nullability-completeness -Wno-unused-parameter -Wno-missing-field-initializers -Wno-unused-private-field -Wno-unused-variable -c -o $@ $<
@ -74,7 +80,7 @@ compile_commands.json: Makefile
clean: clean:
rm -f $(SPV_FILES) rm -f $(SPV_FILES)
rm -f $(ALL_OBJECTS) $(DEP_FILES) rm -f $(ALL_OBJECTS) $(DEP_FILES)
rm -f roleplay editor test/test_hsv rm -f roleplay editor test/test_hsv test/test_editor
rm -rf $(EXTRA_DEBUG_REQUIREMENTS) rm -rf $(EXTRA_DEBUG_REQUIREMENTS)
clean_compdb: clean_compdb:

@ -0,0 +1,63 @@
#ifndef EDITOR_H
#define EDITOR_H
#include "engine.h"
typedef enum EditorModeEnum {
MODE_NONE,
MODE_VERTEX,
MODE_NEIGHBOR,
MODE_HEX,
MODE_REGION,
MODE_MAX_ENUM,
} EditorMode;
typedef struct EditorDataStruct EditorData;
typedef struct ModeKeyStruct ModeKey;
typedef void(*ModeKeyCallback)(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
struct ModeKeyStruct {
int key;
ModeKeyCallback logic;
int axis;
float amount;
};
struct EditorDataStruct {
EditorMode mode;
ModeKey* mode_keys[MODE_MAX_ENUM];
uint32_t mode_key_counts[MODE_MAX_ENUM];
uint32_t selected_max;
uint32_t selected_count;
uint32_t* selected_regions;
uint32_t* selected_hexes;
uint32_t* selected_vertices;
};
EditorData* create_editor_data(void);
void editor_startup(ClientContext* context);
void editor_frame_callback(ClientContext* context);
void editor_key_callback(ClientContext* context, int key, int action, int mods);
void editor_scroll_callback(ClientContext* context, double x, double y);
void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods);
void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void resize_selected(EditorData* data, unsigned int size);
int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex);
int32_t find_selected_vertex(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex);
void add_selected(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex);
void remove_selected_at(EditorData* data, uint32_t index);
bool editor_pick(ClientContext* context, double cursor[2], uint32_t* rid, uint32_t* hid, uint32_t* vertex, bool edge_only);
#endif

@ -166,7 +166,7 @@ typedef struct RenderContextStruct {
bool framebuffer_recreated; bool framebuffer_recreated;
} RenderContext; } RenderContext;
GLFWwindow* init_window(); GLFWwindow* init_window(bool visible);
VkResult init_vulkan( VkResult init_vulkan(
GLFWwindow* window, GLFWwindow* window,

@ -139,6 +139,13 @@ VkResult free_hex_region(
HexContext* hex, HexContext* hex,
RenderContext* gpu); RenderContext* gpu);
void cursor_to_world_ray(
RenderContext* gpu,
mat4 inverse,
double cursor[2],
vec4 start,
vec4 end);
bool ray_world_intersect( bool ray_world_intersect(
float* distance, float* distance,
uint32_t* vertex, uint32_t* vertex,
@ -146,6 +153,7 @@ bool ray_world_intersect(
uint32_t* hid, uint32_t* hid,
vec4 ray_start, vec4 ray_start,
vec4 ray_end, vec4 ray_end,
bool edge_only,
HexContext* context); HexContext* context);
VkResult update_hex_proj( VkResult update_hex_proj(

@ -1,6 +1,7 @@
#include <stdio.h> #include <stdio.h>
#include "hex.h" #include "hex.h"
#include "engine.h" #include "engine.h"
#include "editor.h"
#include "ui_lua.h" #include "ui_lua.h"
#include "vulkan/vulkan_core.h" #include "vulkan/vulkan_core.h"
@ -10,14 +11,9 @@
#define COLOR_PICK_CONTAINER_ID 0x02 #define COLOR_PICK_CONTAINER_ID 0x02
#define MODE_STRING_CONTAINER_ID 0x01 #define MODE_STRING_CONTAINER_ID 0x01
typedef enum EditorModeEnum { #define SELECTION_HIGHLIGHT_OFFSET 0.05f
MODE_NONE, #define SELECTION_POINT_SIZE 12.0f
MODE_VERTEX, #define SELECTION_POINT_OFFSET 0.05f
MODE_NEIGHBOR,
MODE_HEX,
MODE_REGION,
MODE_MAX_ENUM,
} EditorMode;
const char* ModeStrings[] = { const char* ModeStrings[] = {
"None", "None",
@ -27,33 +23,6 @@ const char* ModeStrings[] = {
"Region", "Region",
}; };
typedef struct EditorDataStruct EditorData;
typedef struct ModeKeyStruct ModeKey;
typedef void(*ModeKeyCallback)(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
struct ModeKeyStruct {
int key;
ModeKeyCallback logic;
int axis;
float amount;
};
struct EditorDataStruct {
EditorMode mode;
ModeKey* mode_keys[MODE_MAX_ENUM];
uint32_t mode_key_counts[MODE_MAX_ENUM];
uint32_t selected_max;
uint32_t selected_count;
uint32_t* selected_regions;
uint32_t* selected_hexes;
uint32_t* selected_vertices;
};
uint32_t hex_color(vec4 color) { uint32_t hex_color(vec4 color) {
return ((lrint(color[0]*255.0) & 0xFF) << 24) return ((lrint(color[0]*255.0) & 0xFF) << 24)
+ ((lrint(color[1]*255.0) & 0xFF) << 16) + ((lrint(color[1]*255.0) & 0xFF) << 16)
@ -207,6 +176,120 @@ void editor_scroll_callback(ClientContext* context, double x, double y) {
context->zoom = (int32_t)y; context->zoom = (int32_t)y;
} }
void resize_selected(EditorData* data, unsigned int size) {
uint32_t* new_regions = malloc(sizeof(uint32_t)*size);
uint32_t* new_hexes = malloc(sizeof(uint32_t)*size);
uint32_t* new_vertices = malloc(sizeof(uint32_t)*size);
if(data->selected_regions != NULL) {
memcpy(new_regions, data->selected_regions, data->selected_count*sizeof(uint32_t));
free(data->selected_regions);
}
if(data->selected_hexes != NULL) {
memcpy(new_hexes, data->selected_hexes, data->selected_count*sizeof(uint32_t));
free(data->selected_hexes);
}
if(data->selected_vertices != NULL) {
memcpy(new_vertices, data->selected_vertices, data->selected_count*sizeof(uint32_t));
free(data->selected_vertices);
}
data->selected_regions = new_regions;
data->selected_hexes = new_hexes;
data->selected_vertices = new_vertices;
data->selected_max = size;
}
int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex) {
for(uint32_t i = 0; i < data->selected_count; i++) {
if(data->selected_regions[i] == region && data->selected_hexes[i] == hex) {
return (int32_t)i;
}
}
return -1;
}
int32_t find_selected_vertex(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex) {
for(uint32_t i = 0; i < data->selected_count; i++) {
if(data->selected_regions[i] == region
&& data->selected_hexes[i] == hex
&& data->selected_vertices[i] == vertex) {
return (int32_t)i;
}
}
return -1;
}
void add_selected(EditorData* data, uint32_t region, uint32_t hex, uint32_t vertex) {
if(data->selected_count >= data->selected_max) {
resize_selected(data, data->selected_max == 0 ? 1 : data->selected_max*2);
}
data->selected_regions[data->selected_count] = region;
data->selected_hexes[data->selected_count] = hex;
data->selected_vertices[data->selected_count] = vertex;
data->selected_count++;
}
void remove_selected_at(EditorData* data, uint32_t index) {
for(uint32_t i = index+1; i < data->selected_count; i++) {
data->selected_regions[i-1] = data->selected_regions[i];
data->selected_hexes[i-1] = data->selected_hexes[i];
data->selected_vertices[i-1] = data->selected_vertices[i];
}
data->selected_count--;
}
bool editor_pick(ClientContext* context, double cursor[2], uint32_t* rid, uint32_t* hid, uint32_t* vertex, bool edge_only) {
vec4 start, end;
float distance;
cursor_to_world_ray(&context->render, context->hex.inverse, cursor, start, end);
return ray_world_intersect(&distance, vertex, rid, hid, start, end, edge_only, &context->hex);
}
void sync_hex_highlights(EditorData* data, ClientContext* context) {
for(uint32_t i = 0; i < MAX_HIGHLIGHTS; i++) {
if(i < data->selected_count) {
GPUHighlight temp = {
.color = {1.0f, 0.85f, 0.05f, 1.0f},
.region = data->selected_regions[i],
.hex = data->selected_hexes[i],
.offset = SELECTION_HIGHLIGHT_OFFSET,
};
add_transfers(&temp, context->hex.highlights, sizeof(GPUHighlight)*i, sizeof(GPUHighlight), &context->render);
} else {
uint32_t disabled = 0xFFFFFFFF;
add_transfers(&disabled, context->hex.highlights, sizeof(GPUHighlight)*i + offsetof(GPUHighlight, hex), sizeof(uint32_t), &context->render);
}
}
}
void sync_vertex_points(EditorData* data, ClientContext* context) {
for(uint32_t i = 0; i < MAX_POINTS; i++) {
if(i < data->selected_count) {
GPUPoint temp = {
.color = {1.0f, 0.85f, 0.05f, 1.0f},
.region = data->selected_regions[i],
.hex = data->selected_hexes[i],
.vertex = data->selected_vertices[i],
.size = SELECTION_POINT_SIZE,
.offset = SELECTION_POINT_OFFSET,
};
add_transfers(&temp, context->hex.points, sizeof(GPUPoint)*i, sizeof(GPUPoint), &context->render);
} else {
uint32_t disabled = 0xFFFFFFFF;
add_transfers(&disabled, context->hex.points, sizeof(GPUPoint)*i + offsetof(GPUPoint, hex), sizeof(uint32_t), &context->render);
}
}
}
void refresh_selection_visuals(EditorData* data, ClientContext* context) {
sync_hex_highlights(data, context);
sync_vertex_points(data, context);
}
void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) {
(void)binding; (void)binding;
(void)mods; (void)mods;
@ -214,6 +297,7 @@ void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int
data->mode = MODE_NONE; data->mode = MODE_NONE;
update_mode_string(context, data); update_mode_string(context, data);
data->selected_count = 0; data->selected_count = 0;
refresh_selection_visuals(data, context);
unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui);
} }
} }
@ -225,6 +309,7 @@ void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* bindin
data->mode = MODE_VERTEX; data->mode = MODE_VERTEX;
update_mode_string(context, data); update_mode_string(context, data);
data->selected_count = 0; data->selected_count = 0;
refresh_selection_visuals(data, context);
color_ui(context); color_ui(context);
} }
} }
@ -236,6 +321,7 @@ void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* bind
data->mode = MODE_NEIGHBOR; data->mode = MODE_NEIGHBOR;
update_mode_string(context, data); update_mode_string(context, data);
data->selected_count = 0; data->selected_count = 0;
refresh_selection_visuals(data, context);
color_ui(context); color_ui(context);
} }
} }
@ -247,6 +333,7 @@ void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding,
data->mode = MODE_HEX; data->mode = MODE_HEX;
update_mode_string(context, data); update_mode_string(context, data);
data->selected_count = 0; data->selected_count = 0;
refresh_selection_visuals(data, context);
color_ui(context); color_ui(context);
} }
} }
@ -258,42 +345,49 @@ void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* bindin
data->mode = MODE_REGION; data->mode = MODE_REGION;
update_mode_string(context, data); update_mode_string(context, data);
data->selected_count = 0; data->selected_count = 0;
refresh_selection_visuals(data, context);
unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui);
} }
} }
void editor_startup(ClientContext* context) { void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods) {
mode_string_ui(context); EditorData* data = context->app_data;
// TODO: Remove when region mode is implemented
add_hex_region(context);
}
void resize_selected(EditorData* data, unsigned int size) {
uint32_t* new_regions = malloc(sizeof(uint32_t)*size);
uint32_t* new_hexes = malloc(sizeof(uint32_t)*size);
uint32_t* new_vertices = malloc(sizeof(uint32_t)*size);
if(data->selected_regions != NULL) {
memcpy(new_regions, data->selected_regions, data->selected_count);
free(data->selected_regions);
}
if(data->selected_hexes != NULL) { if(button != GLFW_MOUSE_BUTTON_LEFT || action != GLFW_PRESS) return;
memcpy(new_hexes, data->selected_hexes, data->selected_count); if(data->mode != MODE_VERTEX && data->mode != MODE_HEX) return;
free(data->selected_hexes);
double cursor[2] = {x, y};
uint32_t rid, hid, vertex;
// Vertex mode requests edge-only picking so a click always resolves to a
// real, editable vertex instead of frequently landing on the hex center
// (vertex 0) and giving no feedback.
bool hit = editor_pick(context, cursor, &rid, &hid, &vertex, data->mode == MODE_VERTEX);
if(!hit) return;
uint32_t v = (data->mode == MODE_VERTEX) ? vertex : 0;
int32_t idx = (data->mode == MODE_VERTEX)
? find_selected_vertex(data, rid, hid, v)
: find_selected_hex(data, rid, hid);
// Left click adds to the selection; Shift is reserved for camera movement
// (GLFW_KEY_LEFT_SHIFT) so it carries no selection meaning here. Ctrl
// removes. Mode changes remain the only way to reset the selection.
if(mods & GLFW_MOD_CONTROL) {
if(idx != -1) remove_selected_at(data, (uint32_t)idx);
} else if(idx == -1) {
add_selected(data, rid, hid, v);
} }
if(data->selected_vertices != NULL) { refresh_selection_visuals(data, context);
memcpy(new_vertices, data->selected_vertices, data->selected_count); }
}
data->selected_regions = new_regions; void editor_startup(ClientContext* context) {
data->selected_hexes = new_hexes; mode_string_ui(context);
data->selected_vertices = new_vertices; // TODO: Remove when region mode is implemented
data->selected_max = size; add_hex_region(context);
} }
int main() { EditorData* create_editor_data(void) {
EditorData* data = malloc(sizeof(EditorData)); EditorData* data = malloc(sizeof(EditorData));
memset(data, 0, sizeof(EditorData)); memset(data, 0, sizeof(EditorData));
data->selected_count = 0; data->selected_count = 0;
@ -325,5 +419,5 @@ int main() {
data->mode_keys[MODE_NONE][13] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1}; data->mode_keys[MODE_NONE][13] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1};
data->mode_keys[MODE_NONE][14] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1}; data->mode_keys[MODE_NONE][14] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1};
return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, NULL, editor_scroll_callback, NULL); return data;
} }

@ -0,0 +1,6 @@
#include "editor.h"
int main() {
EditorData* data = create_editor_data();
return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, NULL);
}

@ -2,6 +2,7 @@
#include "ui_lua.h" #include "ui_lua.h"
#include "gpu.h" #include "gpu.h"
#include "draw.h" #include "draw.h"
#include <stdlib.h>
void text_callback(GLFWwindow* window, unsigned int codepoint) { void text_callback(GLFWwindow* window, unsigned int codepoint) {
ClientContext* context = (ClientContext*)glfwGetWindowUserPointer(window); ClientContext* context = (ClientContext*)glfwGetWindowUserPointer(window);
@ -276,8 +277,9 @@ int run_app(
} }
memset(context, 0, sizeof(ClientContext)); memset(context, 0, sizeof(ClientContext));
context->window = init_window(); bool visible = getenv("ROLEPLAY_HEADLESS_TEST") == NULL;
context->window = init_window(visible);
context->rotation[0] = 3*M_PI/2; context->rotation[0] = 3*M_PI/2;
context->rotation[1] = M_PI/4; context->rotation[1] = M_PI/4;

@ -48,13 +48,14 @@ void glfw_error(int error, const char* description) {
fprintf(stderr, "GLFW_ERR: 0x%02x - %s\n", error, description); fprintf(stderr, "GLFW_ERR: 0x%02x - %s\n", error, description);
} }
GLFWwindow* init_window() { GLFWwindow* init_window(bool visible) {
glfwInit(); glfwInit();
glfwSetErrorCallback(glfw_error); glfwSetErrorCallback(glfw_error);
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE);
glfwWindowHint(GLFW_VISIBLE, visible ? GLFW_TRUE : GLFW_FALSE);
GLFWwindow* window = glfwCreateWindow(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, "roleplay", 0, 0); GLFWwindow* window = glfwCreateWindow(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, "roleplay", 0, 0);
glfwSetWindowSizeLimits(window, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, GLFW_DONT_CARE, GLFW_DONT_CARE); glfwSetWindowSizeLimits(window, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT, GLFW_DONT_CARE, GLFW_DONT_CARE);

@ -933,6 +933,7 @@ bool ray_hex_intersect(
vec3 dir, vec3 dir,
vec3 region_offset, vec3 region_offset,
uint32_t hex_index, uint32_t hex_index,
bool edge_only,
HexRegion* region) { HexRegion* region) {
GPUHex* hex = &region->data.hexes[hex_index]; GPUHex* hex = &region->data.hexes[hex_index];
@ -1011,13 +1012,24 @@ bool ray_hex_intersect(
float intersect_distance = glm_vec3_dot(v0v2, q) / det; float intersect_distance = glm_vec3_dot(v0v2, q) / det;
if(intersect_distance < *distance) { if(intersect_distance < *distance) {
*distance = intersect_distance; *distance = intersect_distance;
float w = 1 - u - v; if(edge_only) {
if(u >= v && u >= w) { // Never report the hex center (vertex 0) - always snap to whichever
*vertex = ((triangle + 1) % 6) + 1; // of the triangle's two edge vertices is closer, so vertex-mode
} else if(v >= u && v >= w) { // picking always lands on a real, editable vertex.
*vertex = triangle + 1; if(u >= v) {
*vertex = ((triangle + 1) % 6) + 1;
} else {
*vertex = triangle + 1;
}
} else { } else {
*vertex = 0; float w = 1 - u - v;
if(u >= v && u >= w) {
*vertex = ((triangle + 1) % 6) + 1;
} else if(v >= u && v >= w) {
*vertex = triangle + 1;
} else {
*vertex = 0;
}
} }
} }
} }
@ -1031,6 +1043,7 @@ bool ray_region_intersect(
uint32_t* hid, uint32_t* hid,
vec3 start, vec3 start,
vec3 dir, vec3 dir,
bool edge_only,
HexRegion* region) { HexRegion* region) {
bool intersect = false; bool intersect = false;
float intersect_distance = INFINITY; float intersect_distance = INFINITY;
@ -1042,7 +1055,7 @@ bool ray_region_intersect(
}; };
for(uint32_t intersect_hid = 0; intersect_hid < REGION_HEX_COUNT; intersect_hid++) { for(uint32_t intersect_hid = 0; intersect_hid < REGION_HEX_COUNT; intersect_hid++) {
if(ray_hex_intersect(&intersect_distance, &intersection_vertex, start, dir, region_offset, intersect_hid, region)) { if(ray_hex_intersect(&intersect_distance, &intersection_vertex, start, dir, region_offset, intersect_hid, edge_only, region)) {
if(intersect_distance < *distance) { if(intersect_distance < *distance) {
intersect = true; intersect = true;
*hid = intersect_hid; *hid = intersect_hid;
@ -1062,6 +1075,7 @@ bool ray_world_intersect(
uint32_t* hid, uint32_t* hid,
vec4 ray_start, vec4 ray_start,
vec4 ray_end, vec4 ray_end,
bool edge_only,
HexContext* context) { HexContext* context) {
vec3 start; vec3 start;
start[0] = ray_start[0]/ray_start[3]; start[0] = ray_start[0]/ray_start[3];
@ -1095,7 +1109,7 @@ bool ray_world_intersect(
continue; continue;
} }
if(ray_region_intersect(&intersect_distance, &intersection_vertex, &intersect_hid, start, dir, region)) { if(ray_region_intersect(&intersect_distance, &intersection_vertex, &intersect_hid, start, dir, edge_only, region)) {
if(intersect_distance < *distance) { if(intersect_distance < *distance) {
intersect = true; intersect = true;
*hid = intersect_hid; *hid = intersect_hid;

@ -0,0 +1,171 @@
#include "editor.h"
#include <stdio.h>
#include <stdlib.h>
static int failures = 0;
#define CHECK(cond, msg) do { \
if(!(cond)) { \
fprintf(stderr, "FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); \
failures++; \
} \
} while(0)
// Inverse of cursor_to_world_ray (src/hex.c): forward-projects a world point
// through the current proj*view and solves for the pixel coordinate that
// would ray-cast back to it, so tests can compute exactly which (x, y) will
// land a synthetic click on a given hex vertex/center.
void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2]) {
mat4 view_proj;
glm_mat4_mul(context->hex.data.proj, context->hex.data.view, view_proj);
vec4 point = {world_point[0], world_point[1], world_point[2], 1.0f};
vec4 clip;
glm_mat4_mulv(view_proj, point, clip);
double ndc[2] = {clip[0]/clip[3], clip[1]/clip[3]};
cursor[0] = (ndc[0]/2 + 0.5) * context->render.swapchain_extent.width / context->render.window_scale[0];
cursor[1] = (ndc[1]/2 + 0.5) * context->render.swapchain_extent.height / context->render.window_scale[1];
}
// Positions the camera at a fixed, known, non-degenerate angle above hex 0
// (loaded at the origin by editor_startup) and pushes it through the same
// matrices the real per-frame update would - required up front since a
// one-shot app_frame callback runs before app_main's own camera-update step.
void setup_camera(ClientContext* context) {
context->position[0] = 0;
context->position[1] = 0;
context->position[2] = 0;
// rotation[1] intentionally avoids M_PI/2 (straight down), which is
// parallel to the {0,1,0} up vector and singular for glm_lookat.
context->rotation[0] = 0;
context->rotation[1] = 1.3f;
context->distance = 10;
update_hex_proj(&context->render, &context->hex);
update_hex_view(context->position, context->rotation, context->distance, &context->render, &context->hex);
}
void click(ClientContext* context, vec3 world_point, int mods) {
double cursor[2];
world_to_cursor(context, world_point, cursor);
editor_button_callback(context, (float)cursor[0], (float)cursor[1], GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, mods);
}
void test_hex_mode_click_adds(ClientContext* context) {
EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
vec3 hex0_center = {0, 0, 0};
click(context, hex0_center, 0);
CHECK(data->selected_count == 1, "clicking hex 0 should select it");
CHECK(data->selected_count != 1 || (data->selected_regions[0] == 0 && data->selected_hexes[0] == 0),
"selection should record region 0, hex 0");
// hex_starts[0] is the world-space offset of hex index 1 (radius 1, ring
// 0, side 0 in the hex-ring indexing ray_hex_intersect uses).
vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]};
click(context, hex1_center, 0);
CHECK(data->selected_count == 2, "clicking a second hex should add to, not replace, the selection");
}
void test_hex_mode_ctrl_click_removes(ClientContext* context) {
EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
vec3 hex0_center = {0, 0, 0};
vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]};
click(context, hex0_center, 0);
click(context, hex1_center, 0);
CHECK(data->selected_count == 2, "setup: both hexes should be selected");
click(context, hex0_center, GLFW_MOD_CONTROL);
CHECK(data->selected_count == 1, "ctrl-click should remove the clicked hex");
CHECK(data->selected_count != 1 || data->selected_hexes[0] != 0,
"the remaining selection entry should not be the removed hex");
}
void test_vertex_mode_edge_only_picking(ClientContext* context) {
EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
// Clicking dead center of the hex would, without edge-only picking, hit
// vertex 0 (the center) and select nothing.
vec3 hex0_center = {0, 0, 0};
click(context, hex0_center, 0);
CHECK(data->selected_count == 1, "a center click in vertex mode should still select something");
CHECK(data->selected_count != 1 || (data->selected_vertices[0] >= 1 && data->selected_vertices[0] <= 6),
"vertex mode picking must never resolve to vertex 0");
}
void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) {
EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
vec3 v1 = {hex_vertices[0][0], hex_vertices[0][1], hex_vertices[0][2]};
vec3 v4 = {hex_vertices[3][0], hex_vertices[3][1], hex_vertices[3][2]};
click(context, v1, 0);
click(context, v4, 0);
CHECK(data->selected_count == 2, "clicking two different vertices should add both");
click(context, v1, GLFW_MOD_CONTROL);
CHECK(data->selected_count == 1, "ctrl-click should remove one vertex from the selection");
}
void test_mode_switch_resets_selection(ClientContext* context) {
EditorData* data = context->app_data;
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
click(context, (vec3){0, 0, 0}, 0);
CHECK(data->selected_count == 1, "setup: a hex should be selected before the mode switch");
enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
CHECK(data->selected_count == 0, "switching modes should reset the selection");
}
void test_empty_space_click_is_noop(ClientContext* context) {
EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0);
// A cursor coordinate this far outside the window unprojects to a ray
// angled far off the view axis - guaranteed to miss the small hex region
// clustered near the origin regardless of camera specifics.
editor_button_callback(context, 1e6f, 1e6f, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0);
CHECK(data->selected_count == 0, "clicking empty space should not select anything");
}
void test_frame_callback(ClientContext* context) {
static bool ran = false;
if(ran) return;
ran = true;
setup_camera(context);
test_hex_mode_click_adds(context);
test_hex_mode_ctrl_click_removes(context);
test_vertex_mode_edge_only_picking(context);
test_vertex_mode_click_adds_and_ctrl_removes(context);
test_mode_switch_resets_selection(context);
test_empty_space_click_is_noop(context);
glfwSetWindowShouldClose(context->window, GLFW_TRUE);
}
int main() {
setenv("ROLEPLAY_HEADLESS_TEST", "1", 1);
EditorData* data = create_editor_data();
int result = run_app(data, editor_startup, test_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, NULL);
if(failures > 0) {
fprintf(stderr, "%d test(s) failed\n", failures);
return 1;
}
return result;
}