diff --git a/makefile b/makefile index 9d47cec..7a2d8f9 100644 --- a/makefile +++ b/makefile @@ -30,7 +30,8 @@ EMCC_FLAGS = --use-port=emdawnwebgpu \ -sWASM_BIGINT \ -sALLOW_MEMORY_GROWTH \ -msimd128 \ - -sFILESYSTEM=0 + -sFILESYSTEM=0 \ + -flto # Shell template SHELL_FILE = shell.html diff --git a/src/api.h b/src/api.h index 058b8cc..fa3bdf7 100644 --- a/src/api.h +++ b/src/api.h @@ -29,6 +29,8 @@ #include "util.h" #include "shape.h" +#include "spatial.h" +#include "history.h" #include #include diff --git a/src/history.h b/src/history.h new file mode 100644 index 0000000..43bacf5 --- /dev/null +++ b/src/history.h @@ -0,0 +1,254 @@ +#ifndef HISTORY_H +#define HISTORY_H + +#include "api.h" + +// Each property kind we can undo/redo independently +typedef enum { + HIST_POSITION, + HIST_SCALE, + HIST_ROTATION, + HIST_COLOR, +} hist_prop_t; + +// One property change on one shape (old → new) +typedef struct hist_change_t { + int shape_index; + hist_prop_t prop; + float old_val[4]; + float new_val[4]; +} hist_change_t; + +// A history entry is one or more changes batched together. +// Single-property edits = 1 change. Whole-selection edits = N changes. +typedef struct hist_entry_t { + hist_change_t *changes; + int count; +} hist_entry_t; + +#define HIST_MAX 64 + +typedef struct history_t { + hist_entry_t entries[HIST_MAX]; + int count; + int current; // index of last applied entry, -1 = initial state + + // Pending edit session (one ImGui widget interaction) + bool capturing; + int pending_shape_idx; + hist_prop_t pending_prop; + float pending_old[4]; +} history_t; + +// -- internal helpers -- + +/** + * Read the current value of a single property from a shape. + * + * @param s shape to read from + * @param prop which property (HIST_POSITION, HIST_SCALE, etc.) + * @param out receives the value, zero-padded to 4 floats + */ +static void hist_read_prop(shape_t *s, hist_prop_t prop, float out[4]) { + memset(out, 0, sizeof(float[4])); + switch (prop) { + case HIST_POSITION: out[0] = s->cx; out[1] = s->cy; break; + case HIST_SCALE: out[0] = s->sx; out[1] = s->sy; break; + case HIST_ROTATION: out[0] = s->rotation; break; + case HIST_COLOR: memcpy(out, s->uniform.base_color, sizeof(float[4])); break; + } +} + +/** + * Write a value to a single property of a shape. Does NOT regenerate buffers. + * + * @param s shape to modify in-place + * @param prop which property to set + * @param val new value (4 floats, zero-padded for smaller properties) + */ +static void hist_apply_prop(shape_t *s, hist_prop_t prop, const float val[4]) { + switch (prop) { + case HIST_POSITION: s->cx = val[0]; s->cy = val[1]; break; + case HIST_SCALE: s->sx = val[0]; s->sy = val[1]; break; + case HIST_ROTATION: s->rotation = val[0]; break; + case HIST_COLOR: memcpy(s->uniform.base_color, val, sizeof(float[4])); break; + } +} + +// -- history API -- + +/** + * Zero-initialize the history stack. Call once during app init. + * + * @param h history to initialize + */ +static void history_init(history_t *h) { + memset(h, 0, sizeof(*h)); + h->current = -1; +} + +/** + * Free all heap memory held by the history stack. Call during app shutdown. + * + * @param h history to destroy + */ +static void history_destroy(history_t *h) { + for (int i = 0; i < h->count; i++) { + if (h->entries[i].changes) FREE(h->entries[i].changes); + } + memset(h, 0, sizeof(*h)); + h->current = -1; +} + +/** + * Push a completed entry onto the stack, discarding any redo branch. + * Takes ownership of entry.changes (must be heap-allocated with ALLOC). + * Used internally by begin_edit/end_edit, or directly for batch edits. + * + * @param h history stack + * @param entry entry to push (changes array is consumed, not copied) + */ +static void history_push_entry(history_t *h, hist_entry_t entry) { + while (h->count > h->current + 1) { + h->count--; + if (h->entries[h->count].changes) { + FREE(h->entries[h->count].changes); + h->entries[h->count].changes = NULL; + } + } + + if (h->count >= HIST_MAX) { + if (h->entries[0].changes) FREE(h->entries[0].changes); + memmove(&h->entries[0], &h->entries[1], + (h->count - 1) * sizeof(hist_entry_t)); + h->count--; + h->current--; + } + + h->entries[h->count] = entry; + h->count++; + h->current = h->count - 1; +} + +/** + * Begin capturing an edit session. Snapshots the current value of one property. + * If a prior session is still open (e.g. user switched widgets in the same frame), + * it is finalized and pushed first. + * Call when igIsItemActivated() is true after an ImGui widget. + * + * @param h history stack + * @param shapes the shapes vector (used to read current values) + * @param shape_idx index of the shape being edited + * @param prop which property is about to change + */ +static void history_begin_edit(history_t *h, vector_t *shapes, + int shape_idx, hist_prop_t prop) { + if (h->capturing) { + shape_t *s = (shape_t*) vec_get(shapes, h->pending_shape_idx); + float new_val[4]; + hist_read_prop(s, h->pending_prop, new_val); + if (memcmp(h->pending_old, new_val, sizeof(float[4])) != 0) { + hist_change_t change = { + .shape_index = h->pending_shape_idx, + .prop = h->pending_prop, + }; + memcpy(change.old_val, h->pending_old, sizeof(float[4])); + memcpy(change.new_val, new_val, sizeof(float[4])); + hist_entry_t entry = { .changes = NULL, .count = 1 }; + entry.changes = (hist_change_t*) ALLOC(sizeof(hist_change_t)); + *entry.changes = change; + history_push_entry(h, entry); + } + h->capturing = false; + } + + h->capturing = true; + h->pending_shape_idx = shape_idx; + h->pending_prop = prop; + shape_t *s = (shape_t*) vec_get(shapes, shape_idx); + hist_read_prop(s, prop, h->pending_old); +} + +/** + * End the current edit session and push an entry if the value changed. + * Safe to call when no session is active (no-op). + * Call when igIsAnyItemActive() transitions from true to false. + * + * @param h history stack + * @param shapes the shapes vector (used to read final values) + */ +static void history_end_edit(history_t *h, vector_t *shapes) { + if (!h->capturing) return; + + shape_t *s = (shape_t*) vec_get(shapes, h->pending_shape_idx); + float new_val[4]; + hist_read_prop(s, h->pending_prop, new_val); + + if (memcmp(h->pending_old, new_val, sizeof(float[4])) != 0) { + hist_change_t change = { + .shape_index = h->pending_shape_idx, + .prop = h->pending_prop, + }; + memcpy(change.old_val, h->pending_old, sizeof(float[4])); + memcpy(change.new_val, new_val, sizeof(float[4])); + hist_entry_t entry = { .changes = NULL, .count = 1 }; + entry.changes = (hist_change_t*) ALLOC(sizeof(hist_change_t)); + *entry.changes = change; + history_push_entry(h, entry); + } + h->capturing = false; +} + +/** + * Apply every change in an entry to the shapes vector and regenerate buffers. + * + * @param entry the history entry to apply + * @param shapes the shapes vector to modify + * @param forward true to use new_val (redo), false to use old_val (undo) + */ +static void history_apply_entry(hist_entry_t *entry, vector_t *shapes, bool forward) { + for (int i = 0; i < entry->count; i++) { + hist_change_t *c = &entry->changes[i]; + if (c->shape_index >= shapes->count) continue; + shape_t *s = (shape_t*) vec_get(shapes, c->shape_index); + hist_apply_prop(s, c->prop, forward ? c->new_val : c->old_val); + shape_regenerate(s); + shape_set_state(s, s->hovered, s->selected); + } +} + +/** + * Undo the most recent history entry. + * + * @param h history stack + * @param shapes the shapes vector to revert + * @param selected_count out-parameter for updated selection count (currently passed through) + * @return true if state was changed, false if nothing to undo + */ +static bool history_undo(history_t *h, vector_t *shapes, int *selected_count) { + if (h->current < 0) return false; + + history_apply_entry(&h->entries[h->current], shapes, false); + h->current--; + (void)selected_count; + return true; +} + +/** + * Redo the next history entry. + * + * @param h history stack + * @param shapes the shapes vector to advance + * @param selected_count out-parameter (currently passed through) + * @return true if state was changed, false if nothing to redo + */ +static bool history_redo(history_t *h, vector_t *shapes, int *selected_count) { + if (h->current + 1 >= h->count) return false; + + h->current++; + history_apply_entry(&h->entries[h->current], shapes, true); + (void)selected_count; + return true; +} + +#endif diff --git a/src/main.c b/src/main.c index 9e76905..3611dd1 100644 --- a/src/main.c +++ b/src/main.c @@ -1,8 +1,10 @@ #include "api.h" -#define GRID_X 1000 -#define GRID_Y 1000 #define LOG_RING_SIZE 64 +#define HANDLE_OFFSET_PX 0.0f +#define HANDLE_RADIUS_PX 12.0f +#define HANDLE_CIRCLE_SEGMENTS 256 +#define CORNER_SIZE_PX 8.0f typedef struct log_entry_t { char text[256]; @@ -24,15 +26,25 @@ typedef struct dragger_t { float origin_x, origin_y; } dragger_t; +typedef struct { + int idx; + float init_sx, init_sy, init_cx, init_cy; + float ext_x, ext_y; + float lpi_x, lpi_y; +} resize_init_t; + typedef struct userdata_t { int width, height; + float half_width, half_height; vec2 pan; float zoom; dragger_t dragger; renderer_t renderer; vector_t shapes; + spatial_grid_t spatial_grid; int selected_count; int hovered_shape; + float hover_tol; bool selecting; float sel_sx, sel_sy; @@ -40,7 +52,44 @@ typedef struct userdata_t { bool sel_dragging; int sel_clicked_shape; + float panel_w; + + bool move_dragging; + float move_start_wx, move_start_wy; + float move_total_dx, move_total_dy; + + bool rotate_dragging; + float rotate_center_x, rotate_center_y; + float rotate_start_angle; + float rotate_total_delta; + float handle_radius; + + bool resize_dragging; + float resize_pivot_x, resize_pivot_y; + float resize_start_wx, resize_start_wy; + float resize_total_scale_x, resize_total_scale_y; + float resize_mask_x, resize_mask_y; + float resize_angle; + resize_init_t *resize_init; + int resize_init_count; + sg_buffer rect_vbuf, rect_ibuf; + sg_buffer handle_vbuf, handle_ibuf; + sg_buffer corner_vbuf, corner_ibuf; + + history_t history; + + bool overlay_upload_needed; + + float cached_aabb[4]; + bool aabb_cached; + + float fps_immediate; + float fps_average; + float frame_times[60]; + int frame_time_head; + int frame_time_count; + float frame_time_sum; log_entry_t log_ring[LOG_RING_SIZE]; int log_head; @@ -48,9 +97,16 @@ typedef struct userdata_t { bool log_show; } userdata_t; +/** + * Format a string into a static buffer. Only valid until the next call. + * + * @param format printf-style format string + * @param ... variadic arguments + * @return pointer to internal static buffer (single-use) + */ const char* format(const char* format, ...) { - char buffer[_SLOG_LINE_LENGTH]; + static char buffer[_SLOG_LINE_LENGTH]; va_list va; va_start(va, format); @@ -61,6 +117,11 @@ const char* format(const char* format, ...) return buffer; } +/** + * Recompute the Model-View-Projection matrix from current pan and zoom. + * + * @param userdata application state + */ void compute_mvp(userdata_t *userdata) { const float w = (float)userdata->width; @@ -90,14 +151,69 @@ void compute_mvp(userdata_t *userdata) (*m)[3][2] = 0.0f; (*m)[3][3] = 1.0f; } +/** + * Convert screen-space mouse coordinates to world-space coordinates. + * + * @param ud application state + * @param mx mouse X in screen pixels + * @param my mouse Y in screen pixels + * @param wx receives world X + * @param wy receives world Y + */ static void screen_to_world(userdata_t *ud, float mx, float my, float *wx, float *wy) { - const float sx = mx - ud->width * 0.5f; - const float sy = ud->height * 0.5f - my; + const float sx = mx - ud->half_width; + const float sy = ud->half_height - my; *wx = (sx - ud->pan[0]) / ud->zoom; *wy = (sy - ud->pan[1]) / ud->zoom; } +/** + * Compute the axis-aligned bounding box of all selected shapes. + * If no shapes are selected, min/max are unchanged. + * + * @param ud application state + * @param min_x receives minimum X in world space + * @param min_y receives minimum Y in world space + * @param max_x receives maximum X in world space + * @param max_y receives maximum Y in world space + */ +static void selected_aabb(userdata_t *ud, float *min_x, float *min_y, + float *max_x, float *max_y) +{ + bool first = true; + for (int i = 0; i < ud->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&ud->shapes, i); + if (!s->selected) continue; + float sc = cosf(s->rotation), ss = sinf(s->rotation); + for (uint32_t v = 0; v < s->num_verts; v++) { + float lx = s->verts[v].x * s->sx; + float ly = s->verts[v].y * s->sy; + float wx = s->cx + lx * sc - ly * ss; + float wy = s->cy + lx * ss + ly * sc; + if (first) { *min_x = *max_x = wx; *min_y = *max_y = wy; first = false; } + else { + if (wx < *min_x) *min_x = wx; + if (wx > *max_x) *max_x = wx; + if (wy < *min_y) *min_y = wy; + if (wy > *max_y) *max_y = wy; + } + } + } +} + +/** + * Sokol log callback. Formats log entries into the in-app ring buffer and + * prints them to stderr. Errors and panics auto-open the log panel. + * + * @param tag log tag + * @param log_level 0=panic, 1=error, 2=warn, 3=info + * @param log_item log item id + * @param message log message string + * @param line_nr source line number + * @param filename source file name + * @param user_data pointer to userdata_t + */ static void log_capture(const char* tag, uint32_t log_level, uint32_t log_item, const char* message, uint32_t line_nr, const char* filename, void* user_data) @@ -132,77 +248,225 @@ static void log_capture(const char* tag, uint32_t log_level, uint32_t log_item, if (log_level <= 1) ud->log_show = true; } +/** + * Per-frame render callback. Draws all shapes, the selection overlay, and the + * ImGui UI (properties panel and log panel). + * + * @param _userdata pointer to userdata_t + */ static void frame(void* _userdata) { userdata_t* userdata = (userdata_t*) _userdata; + shape_begin_frame(); + + { + float dt = (float)sapp_frame_duration(); + float instant_fps = dt > 0.0001f ? 1.0f / dt : 0.0f; + userdata->fps_immediate += (instant_fps - userdata->fps_immediate) * 0.1f; + + int idx = userdata->frame_time_head; + if (userdata->frame_time_count == 60) { + userdata->frame_time_sum -= userdata->frame_times[idx]; + } else { + userdata->frame_time_count++; + } + userdata->frame_times[idx] = dt; + userdata->frame_time_sum += dt; + userdata->frame_time_head = (idx + 1) % 60; + userdata->fps_average = userdata->frame_time_sum > 0.0001f + ? (float)userdata->frame_time_count / userdata->frame_time_sum : 0.0f; + } + + spatial_rebuild(&userdata->spatial_grid, &userdata->shapes); + + // -- pre-pass: compute overlay/handle data and update GPU buffers -- + + bool has_overlay = false; + shape_vertex_t overlay_verts[5]; + + float sel_cx = 0, sel_cy = 0, sel_angle = 0; + float sel_hw = 0, sel_hh = 0; + + if (userdata->selecting && userdata->sel_dragging) { + float wx1, wy1, wx2, wy2; + screen_to_world(userdata, userdata->sel_sx, userdata->sel_sy, &wx1, &wy1); + screen_to_world(userdata, userdata->sel_cx, userdata->sel_cy, &wx2, &wy2); + float x1 = fminf(wx1, wx2), y1 = fminf(wy1, wy2); + float x2 = fmaxf(wx1, wx2), y2 = fmaxf(wy1, wy2); + overlay_verts[0] = (shape_vertex_t){x1, y1}; + overlay_verts[1] = (shape_vertex_t){x2, y1}; + overlay_verts[2] = (shape_vertex_t){x2, y2}; + overlay_verts[3] = (shape_vertex_t){x1, y2}; + overlay_verts[4] = (shape_vertex_t){x1, y1}; + has_overlay = true; + } else if (userdata->selected_count >= 1) { + if (userdata->selected_count == 1) { + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (!s->selected) continue; + sel_cx = s->cx; sel_cy = s->cy; + sel_hw = s->sx; sel_hh = s->sy; + sel_angle = s->rotation; + float x1, y1, x2, y2; + selected_aabb(userdata, &x1, &y1, &x2, &y2); + userdata->cached_aabb[0] = x1; userdata->cached_aabb[1] = y1; + userdata->cached_aabb[2] = x2; userdata->cached_aabb[3] = y2; + userdata->aabb_cached = true; + overlay_verts[0] = (shape_vertex_t){x1, y1}; + overlay_verts[1] = (shape_vertex_t){x2, y1}; + overlay_verts[2] = (shape_vertex_t){x2, y2}; + overlay_verts[3] = (shape_vertex_t){x1, y2}; + overlay_verts[4] = overlay_verts[0]; + break; + } + } else { + float omin[2], omax[2]; + float sum_sin = 0, sum_cos = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (!s->selected) continue; + sum_sin += sinf(s->rotation); + sum_cos += cosf(s->rotation); + } + selected_aabb(userdata, &omin[0], &omin[1], &omax[0], &omax[1]); + userdata->cached_aabb[0] = omin[0]; userdata->cached_aabb[1] = omin[1]; + userdata->cached_aabb[2] = omax[0]; userdata->cached_aabb[3] = omax[1]; + userdata->aabb_cached = true; + float pad = 8.0f / userdata->zoom; + omin[0] -= pad; omin[1] -= pad; + omax[0] += pad; omax[1] += pad; + sel_cx = (omin[0] + omax[0]) * 0.5f; + sel_cy = (omin[1] + omax[1]) * 0.5f; + sel_hw = (omax[0] - omin[0]) * 0.5f; + sel_hh = (omax[1] - omin[1]) * 0.5f; + sel_angle = atan2f(sum_sin, sum_cos); + + overlay_verts[0] = (shape_vertex_t){omin[0], omin[1]}; + overlay_verts[1] = (shape_vertex_t){omax[0], omin[1]}; + overlay_verts[2] = (shape_vertex_t){omax[0], omax[1]}; + overlay_verts[3] = (shape_vertex_t){omin[0], omax[1]}; + overlay_verts[4] = overlay_verts[0]; + } + has_overlay = true; + } + + bool need_upload = userdata->overlay_upload_needed || + userdata->move_dragging || userdata->rotate_dragging || + userdata->resize_dragging || userdata->selecting; + + if (has_overlay && need_upload) { + sg_update_buffer(userdata->rect_vbuf, &(sg_range){overlay_verts, sizeof(overlay_verts)}); + } + + bool show_handle = userdata->selected_count > 0 && !userdata->selecting; + if (show_handle) { + float pad = HANDLE_OFFSET_PX / userdata->zoom; + float radius = sqrtf(sel_hw * sel_hw + sel_hh * sel_hh) + pad; + + userdata->rotate_center_x = sel_cx; + userdata->rotate_center_y = sel_cy; + userdata->handle_radius = radius; + + const int n = HANDLE_CIRCLE_SEGMENTS + 1; + shape_vertex_t hv[HANDLE_CIRCLE_SEGMENTS + 1]; + for (int i = 0; i < n; i++) { + float a = (float)i / (float)HANDLE_CIRCLE_SEGMENTS * 2.0f * GLM_PIf; + hv[i] = (shape_vertex_t){sel_cx + cosf(a) * radius, sel_cy + sinf(a) * radius}; + } + if (need_upload) + sg_update_buffer(userdata->handle_vbuf, &(sg_range){hv, sizeof(hv)}); + + // resize handles: 4 corners + 4 edge midpoints + { + float hs = CORNER_SIZE_PX / userdata->zoom * 0.5f; + float mid_x = (overlay_verts[0].x + overlay_verts[1].x) * 0.5f; + float mid_y = (overlay_verts[0].y + overlay_verts[2].y) * 0.5f; + float handles[8][2] = { + {overlay_verts[0].x, overlay_verts[0].y}, // bottom-left corner + {mid_x, overlay_verts[0].y}, // bottom edge + {overlay_verts[1].x, overlay_verts[1].y}, // bottom-right corner + {overlay_verts[1].x, mid_y }, // right edge + {overlay_verts[2].x, overlay_verts[2].y}, // top-right corner + {mid_x, overlay_verts[2].y}, // top edge + {overlay_verts[3].x, overlay_verts[3].y}, // top-left corner + {overlay_verts[3].x, mid_y }, // left edge + }; + shape_vertex_t cv[40]; + for (int h = 0; h < 8; h++) { + float cx = handles[h][0], cy = handles[h][1]; + cv[h*5+0] = (shape_vertex_t){cx - hs, cy - hs}; + cv[h*5+1] = (shape_vertex_t){cx + hs, cy - hs}; + cv[h*5+2] = (shape_vertex_t){cx + hs, cy + hs}; + cv[h*5+3] = (shape_vertex_t){cx - hs, cy + hs}; + cv[h*5+4] = (shape_vertex_t){cx - hs, cy - hs}; + } + if (need_upload) + sg_update_buffer(userdata->corner_vbuf, &(sg_range){cv, sizeof(cv)}); + } + } + userdata->overlay_upload_needed = false; + + // -- render pass -- sg_begin_pass(&(sg_pass){ .action = userdata->renderer.clear_pass, .swapchain = sglue_swapchain(), }); + sg_apply_pipeline(shape_pipeline); for (int i = 0; i < userdata->shapes.count; i++) { shape_draw((shape_t*) vec_get(&userdata->shapes, i), &userdata->renderer.uniform.mvp); } - // Draw selection overlay (marquee during drag, AABB for multi-select) - { - float omin[2], omax[2]; - bool has_overlay = false; + if (has_overlay) { + shape_uniform_t u; + glm_mat4_identity(u.transform); + u.base_color[0] = 0.3f; u.base_color[1] = 0.5f; + u.base_color[2] = 1.0f; u.base_color[3] = 0.8f; + u.state = 0; + memset(u._pad, 0, sizeof(u._pad)); - if (userdata->selecting && userdata->sel_dragging) { - float wx1, wy1, wx2, wy2; - screen_to_world(userdata, userdata->sel_sx, userdata->sel_sy, &wx1, &wy1); - screen_to_world(userdata, userdata->sel_cx, userdata->sel_cy, &wx2, &wy2); - omin[0] = fminf(wx1, wx2); omin[1] = fminf(wy1, wy2); - omax[0] = fmaxf(wx1, wx2); omax[1] = fmaxf(wy1, wy2); - has_overlay = true; - } else if (userdata->selected_count >= 2) { - bool first = true; - for (int i = 0; i < userdata->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); - if (!s->selected) continue; - for (uint32_t v = 0; v < s->num_verts; v++) { - float vx = s->verts[v].x, vy = s->verts[v].y; - if (first) { - omin[0] = omax[0] = vx; - omin[1] = omax[1] = vy; - first = false; - } else { - if (vx < omin[0]) omin[0] = vx; - if (vx > omax[0]) omax[0] = vx; - if (vy < omin[1]) omin[1] = vy; - if (vy > omax[1]) omax[1] = vy; - } - } - } - float pad = 8.0f / userdata->zoom; - omin[0] -= pad; omin[1] -= pad; - omax[0] += pad; omax[1] += pad; - has_overlay = true; - } + sg_apply_uniforms(0, &SG_RANGE(userdata->renderer.uniform.mvp)); + sg_apply_uniforms(1, &SG_RANGE(u)); + sg_apply_bindings(&(sg_bindings){ + .vertex_buffers[0] = userdata->rect_vbuf, + .index_buffer = userdata->rect_ibuf, + }); + sg_draw(0, 5, 1); + } - if (has_overlay) { - shape_vertex_t rect_verts[4] = { - {omin[0], omin[1]}, {omax[0], omin[1]}, {omax[0], omax[1]}, {omin[0], omax[1]}, - }; - sg_update_buffer(userdata->rect_vbuf, &(sg_range){rect_verts, sizeof(rect_verts)}); + if (show_handle) { + shape_uniform_t hu; + glm_mat4_identity(hu.transform); + hu.base_color[0] = 0.3f; hu.base_color[1] = 0.5f; + hu.base_color[2] = 1.0f; hu.base_color[3] = 0.8f; + hu.state = 0; + memset(hu._pad, 0, sizeof(hu._pad)); - shape_uniform_t u; - glm_mat4_identity(u.transform); - u.base_color[0] = 0.3f; u.base_color[1] = 0.5f; - u.base_color[2] = 1.0f; u.base_color[3] = 0.35f; - u.state = 0; - memset(u._pad, 0, sizeof(u._pad)); + sg_apply_uniforms(0, &SG_RANGE(userdata->renderer.uniform.mvp)); + sg_apply_uniforms(1, &SG_RANGE(hu)); + sg_apply_bindings(&(sg_bindings){ + .vertex_buffers[0] = userdata->handle_vbuf, + .index_buffer = userdata->handle_ibuf, + }); + sg_draw(0, HANDLE_CIRCLE_SEGMENTS + 1, 1); + + // corner resize handles + { + shape_uniform_t cu; + glm_mat4_identity(cu.transform); + cu.base_color[0] = 1.0f; cu.base_color[1] = 1.0f; + cu.base_color[2] = 1.0f; cu.base_color[3] = 0.9f; + cu.state = 0; + memset(cu._pad, 0, sizeof(cu._pad)); - sg_apply_pipeline(overlay_pipeline); sg_apply_uniforms(0, &SG_RANGE(userdata->renderer.uniform.mvp)); - sg_apply_uniforms(1, &SG_RANGE(u)); + sg_apply_uniforms(1, &SG_RANGE(cu)); sg_apply_bindings(&(sg_bindings){ - .vertex_buffers[0] = userdata->rect_vbuf, - .index_buffer = userdata->rect_ibuf, + .vertex_buffers[0] = userdata->corner_vbuf, + .index_buffer = userdata->corner_ibuf, }); - sg_draw(0, 6, 1); + for (int h = 0; h < 8; h++) sg_draw(h * 5, 5, 1); } } @@ -215,9 +479,8 @@ static void frame(void* _userdata) // Properties panel { - const float panel_w = 250.0f; - igSetNextWindowPos((ImVec2){userdata->width - panel_w, 20.0f}, ImGuiCond_Always, (ImVec2){0, 0}); - igSetNextWindowSize((ImVec2){panel_w, userdata->height - 20.0f}, ImGuiCond_Always); + igSetNextWindowPos((ImVec2){userdata->width - userdata->panel_w, 20.0f}, ImGuiCond_Always, (ImVec2){0, 0}); + igSetNextWindowSize((ImVec2){userdata->panel_w, userdata->height - 20.0f}, ImGuiCond_Always); igBegin("Properties", NULL, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse); @@ -236,11 +499,53 @@ static void frame(void* _userdata) bool changed = false; changed |= igDragFloat2("Position", &s->cx, 1.0f, 0, 0, "%.1f", 0); + if (igIsItemActivated()) history_begin_edit(&userdata->history, &userdata->shapes, idx, HIST_POSITION); changed |= igDragFloat2("Scale", &s->sx, 1.0f, 0.1f, 0, "%.1f", 0); + if (igIsItemActivated()) history_begin_edit(&userdata->history, &userdata->shapes, idx, HIST_SCALE); changed |= igDragFloat("Rotation", &s->rotation, 0.01f, 0, 0, "%.3f", 0); - igColorEdit4("Color", s->uniform.base_color, 0); + if (igIsItemActivated()) history_begin_edit(&userdata->history, &userdata->shapes, idx, HIST_ROTATION); + if (igColorEdit4("Color", s->uniform.base_color, 0)) + changed = true; + if (igIsItemActivated()) history_begin_edit(&userdata->history, &userdata->shapes, idx, HIST_COLOR); - if (changed) shape_regenerate(s); + if (changed) { shape_regenerate(s); spatial_mark_dirty(&userdata->spatial_grid); userdata->overlay_upload_needed = true; } + + igSeparator(); + { + mat4 *m = &s->uniform.transform; + float sc = cosf(s->rotation), ss = sinf(s->rotation); + float lx0 = s->verts[0].x * s->sx; + float ly0 = s->verts[0].y * s->sy; + float wx = s->cx + lx0 * sc - ly0 * ss; + float wy = s->cy + lx0 * ss + ly0 * sc; + igText("Debug - Transform Matrix:"); + igText("[%+.3f %+.3f %+.3f %+.3f]", (*m)[0][0], (*m)[0][1], (*m)[0][2], (*m)[0][3]); + igText("[%+.3f %+.3f %+.3f %+.3f]", (*m)[1][0], (*m)[1][1], (*m)[1][2], (*m)[1][3]); + igText("[%+.3f %+.3f %+.3f %+.3f]", (*m)[2][0], (*m)[2][1], (*m)[2][2], (*m)[2][3]); + igText("[%+.3f %+.3f %+.3f %+.3f]", (*m)[3][0], (*m)[3][1], (*m)[3][2], (*m)[3][3]); + igSpacing(); + igText("Local vert[0]: (%.1f, %.1f)", s->verts[0].x, s->verts[0].y); + igText("World vert[0]: (%.1f, %.1f)", wx, wy); + igText("cx=%.1f cy=%.1f sx=%.1f sy=%.1f rot=%.3f", s->cx, s->cy, s->sx, s->sy, s->rotation); + char dbg[512]; + snprintf(dbg, sizeof(dbg), + "Transform Matrix:\n" + "[%+.3f %+.3f %+.3f %+.3f]\n" + "[%+.3f %+.3f %+.3f %+.3f]\n" + "[%+.3f %+.3f %+.3f %+.3f]\n" + "[%+.3f %+.3f %+.3f %+.3f]\n" + "\nLocal vert[0]: (%.1f, %.1f)\n" + "World vert[0]: (%.1f, %.1f)\n" + "cx=%.1f cy=%.1f sx=%.1f sy=%.1f rot=%.3f", + (*m)[0][0], (*m)[0][1], (*m)[0][2], (*m)[0][3], + (*m)[1][0], (*m)[1][1], (*m)[1][2], (*m)[1][3], + (*m)[2][0], (*m)[2][1], (*m)[2][2], (*m)[2][3], + (*m)[3][0], (*m)[3][1], (*m)[3][2], (*m)[3][3], + s->verts[0].x, s->verts[0].y, wx, wy, + s->cx, s->cy, s->sx, s->sy, s->rotation); + if (igButton("Copy Debug", (ImVec2){0, 0})) + sapp_set_clipboard_string(dbg); + } igSeparator(); @@ -262,6 +567,10 @@ static void frame(void* _userdata) } igEnd(); + + if (userdata->history.capturing && !igIsAnyItemActive()) { + history_end_edit(&userdata->history, &userdata->shapes); + } } // Log panel @@ -274,7 +583,23 @@ static void frame(void* _userdata) userdata->log_count = 0; } igSameLine(0.0f, 10.0f); + if (igButton("Copy", (ImVec2){0, 0})) { + int total = userdata->log_count < LOG_RING_SIZE ? userdata->log_count : LOG_RING_SIZE; + int start = userdata->log_count < LOG_RING_SIZE ? 0 : userdata->log_head; + int cap = total * 260; + char *buf = (char*) ALLOC((size_t)cap); + int off = 0; + for (int i = 0; i < total; i++) { + int idx = (start + i) % LOG_RING_SIZE; + off += snprintf(buf + off, (size_t)(cap - off), "%s\n", userdata->log_ring[idx].text); + } + igSetClipboardText(buf); + FREE(buf); + } + igSameLine(0.0f, 10.0f); igText("%d entries", userdata->log_count); + igSameLine(0.0f, 10.0f); + igText("FPS: %.0f (avg: %.0f)", userdata->fps_immediate, userdata->fps_average); igSeparator(); igBeginChild_Str("LogScroll", (ImVec2){0, 0}, false, 0); @@ -305,6 +630,12 @@ static void frame(void* _userdata) sg_commit(); } +/** + * One-time app initialization. Sets up sokol_gfx, simgui, shaders, pipelines, + * creates the initial shapes, and initializes the history stack. + * + * @param _userdata pointer to userdata_t + */ static void init(void* _userdata) { rand_seed(1); @@ -341,8 +672,11 @@ static void init(void* _userdata) userdata->width = sapp_width(); userdata->height = sapp_height(); + userdata->half_width = userdata->width * 0.5f; + userdata->half_height = userdata->height * 0.5f; glm_vec2_zero(userdata->pan); - userdata->zoom = 2; + userdata->zoom = 0.5f; + userdata->hover_tol = SHAPE_HOVER_PX / userdata->zoom; sg_shader sprite_shader = sg_make_shader(&(sg_shader_desc) { .vertex_func = { @@ -423,38 +757,95 @@ static void init(void* _userdata) shape_init_pipeline(); vec_init(&userdata->shapes, sizeof(shape_t)); + spatial_init(&userdata->spatial_grid); userdata->selected_count = 0; userdata->hovered_shape = -1; userdata->selecting = false; userdata->sel_dragging = false; + userdata->panel_w = 300; + userdata->move_dragging = false; + userdata->rotate_dragging = false; + userdata->resize_dragging = false; + userdata->resize_angle = 0.0f; + userdata->resize_init = NULL; + userdata->overlay_upload_needed = true; + userdata->resize_init_count = 0; userdata->log_head = 0; userdata->log_count = 0; userdata->log_show = false; { - shape_vertex_t dummy[4] = {0}; - uint16_t indices[6] = {0, 1, 2, 0, 2, 3}; userdata->rect_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = 5 * sizeof(shape_vertex_t), .usage = { .stream_update = true }, - .data = {dummy, sizeof(dummy)}, .label = "Sel rect verts", }); + uint16_t rect_idx[5] = {0, 1, 2, 3, 4}; userdata->rect_ibuf = sg_make_buffer(&(sg_buffer_desc){ .usage = {.index_buffer = true}, - .data = {indices, sizeof(indices)}, + .data = {rect_idx, sizeof(rect_idx)}, .label = "Sel rect indices", }); } + { + const int n = HANDLE_CIRCLE_SEGMENTS + 1; + uint16_t handle_idx[HANDLE_CIRCLE_SEGMENTS + 1]; + for (int i = 0; i < n; i++) handle_idx[i] = (uint16_t)i; + userdata->handle_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = (size_t)n * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Handle verts", + }); + userdata->handle_ibuf = sg_make_buffer(&(sg_buffer_desc){ + .usage = {.index_buffer = true}, + .data = {handle_idx, sizeof(handle_idx)}, + .label = "Handle indices", + }); + } + + { + userdata->corner_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = 40 * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Corner verts", + }); + uint16_t ci[40]; + for (int i = 0; i < 40; i++) ci[i] = (uint16_t)i; + userdata->corner_ibuf = sg_make_buffer(&(sg_buffer_desc){ + .usage = {.index_buffer = true}, + .data = {ci, sizeof(ci)}, + .label = "Corner indices", + }); + } + *((shape_t*) vec_push(&userdata->shapes)) = shape_star(0.0f, 0.0f, 200.0f, 80.0f, 7, (float[4]){ 0.0f, 0.94f, 1.0f, 1.0f }); *((shape_t*) vec_push(&userdata->shapes)) = shape_circle(300.0f, 0.0f, 120.0f, (float[4]){ 1.0f, 0.47f, 0.0f, 1.0f }); + history_init(&userdata->history); + + EM_ASM({ + window.addEventListener('keydown', function(e) { + if (e.ctrlKey && !e.altKey && !e.metaKey) { + if (e.key === 'z' || e.key === 'y') { + e.preventDefault(); + } + } + }, true); + }); + compute_mvp(userdata); } +/** + * App shutdown callback. Frees all shapes, GPU buffers, the history stack, + * and tears down simgui and sokol. + * + * @param _userdata pointer to userdata_t + */ static void cleanup(void* _userdata) { userdata_t* userdata = (userdata_t*) _userdata; @@ -462,9 +853,16 @@ static void cleanup(void* _userdata) for (int i = 0; i < userdata->shapes.count; i++) { shape_shutdown((shape_t*) vec_get(&userdata->shapes, i)); } + spatial_destroy(&userdata->spatial_grid); vec_free(&userdata->shapes); + history_destroy(&userdata->history); + if (userdata->resize_init) FREE(userdata->resize_init); sg_destroy_buffer(userdata->rect_vbuf); sg_destroy_buffer(userdata->rect_ibuf); + sg_destroy_buffer(userdata->handle_vbuf); + sg_destroy_buffer(userdata->handle_ibuf); + sg_destroy_buffer(userdata->corner_vbuf); + sg_destroy_buffer(userdata->corner_ibuf); shape_shutdown_pipeline(); FREE(userdata); @@ -473,16 +871,54 @@ static void cleanup(void* _userdata) sg_shutdown(); } +/** + * Input event handler. Processes keyboard shortcuts (Ctrl+Z/Y for undo/redo), + * then delegates to simgui. Handles mouse down/up/move/scroll for pan, zoom, + * single-click selection, marquee selection, and Ctrl+click toggle. + * + * @param event sokol event descriptor + * @param _userdata pointer to userdata_t + */ static void event(const sapp_event* event, void* _userdata) { - if (simgui_handle_event(event)) return; userdata_t* userdata = (userdata_t*) _userdata; + if (event->type == SAPP_EVENTTYPE_KEY_DOWN) { + if (event->modifiers & SAPP_MODIFIER_CTRL) { + if (event->key_code == SAPP_KEYCODE_Z || event->key_code == SAPP_KEYCODE_W) { + if (history_undo(&userdata->history, &userdata->shapes, &userdata->selected_count)) { + userdata->hovered_shape = -1; + spatial_mark_dirty(&userdata->spatial_grid); + userdata->aabb_cached = false; + userdata->overlay_upload_needed = true; + } + return; + } + if (event->key_code == SAPP_KEYCODE_Y) { + if (history_redo(&userdata->history, &userdata->shapes, &userdata->selected_count)) { + userdata->hovered_shape = -1; + spatial_mark_dirty(&userdata->spatial_grid); + userdata->aabb_cached = false; + userdata->overlay_upload_needed = true; + } + return; + } + } + if (event->key_code == SAPP_KEYCODE_GRAVE_ACCENT) { + userdata->log_show = !userdata->log_show; + return; + } + } + + if (simgui_handle_event(event)) return; + switch(event->type) { case SAPP_EVENTTYPE_RESIZED: userdata->width = sapp_width(); userdata->height = sapp_height(); + userdata->half_width = userdata->width * 0.5f; + userdata->half_height = userdata->height * 0.5f; compute_mvp(userdata); @@ -500,23 +936,141 @@ static void event(const sapp_event* event, void* _userdata) if (shape_hit_test(s, wx, wy, tol)) { s->selected = !s->selected; userdata->selected_count += s->selected ? 1 : -1; + userdata->overlay_upload_needed = true; break; } } } else { - userdata->selecting = true; - userdata->sel_dragging = false; - userdata->sel_sx = event->mouse_x; - userdata->sel_sy = event->mouse_y; - userdata->sel_cx = event->mouse_x; - userdata->sel_cy = event->mouse_y; - - userdata->sel_clicked_shape = -1; - for (int i = 0; i < userdata->shapes.count; i++) { - if (shape_hit_test((shape_t*) vec_get(&userdata->shapes, i), wx, wy, tol)) { - userdata->sel_clicked_shape = i; - break; + int resize_hit = -1; + if (userdata->selected_count > 0) { + float omin[2], omax[2]; + if (userdata->aabb_cached) { + omin[0] = userdata->cached_aabb[0]; omin[1] = userdata->cached_aabb[1]; + omax[0] = userdata->cached_aabb[2]; omax[1] = userdata->cached_aabb[3]; + } else { + selected_aabb(userdata, &omin[0], &omin[1], &omax[0], &omax[1]); } + float hs = CORNER_SIZE_PX / userdata->zoom * 0.5f + tol; + float mid_x = (omin[0] + omax[0]) * 0.5f; + float mid_y = (omin[1] + omax[1]) * 0.5f; + float hx[8] = {omin[0], mid_x, omax[0], omax[0], omax[0], mid_x, omin[0], omin[0]}; + float hy[8] = {omin[1], omin[1], omin[1], mid_y, omax[1], omax[1], omax[1], mid_y}; + for (int h = 0; h < 8; h++) { + if (fabsf(wx - hx[h]) <= hs && fabsf(wy - hy[h]) <= hs) { + resize_hit = h; + break; + } + } + if (resize_hit >= 0) { + float px[8] = {omax[0], mid_x, omin[0], omin[0], omin[0], mid_x, omax[0], omax[0]}; + float py[8] = {omax[1], omax[1], omax[1], mid_y, omin[1], omin[1], omin[1], mid_y}; + userdata->resize_pivot_x = px[resize_hit]; + userdata->resize_pivot_y = py[resize_hit]; + userdata->resize_start_wx = wx; + userdata->resize_start_wy = wy; + userdata->resize_total_scale_x = 1.0f; + userdata->resize_total_scale_y = 1.0f; + userdata->resize_mask_x = (resize_hit == 3 || resize_hit == 7 || (resize_hit & 1) == 0) ? 1.0f : 0.0f; + userdata->resize_mask_y = (resize_hit == 1 || resize_hit == 5 || (resize_hit & 1) == 0) ? 1.0f : 0.0f; + userdata->resize_dragging = true; + + float sum_sin = 0, sum_cos = 0; + int sel_n = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected) { sum_sin += sinf(s->rotation); sum_cos += cosf(s->rotation); sel_n++; } + } + userdata->resize_angle = atan2f(sum_sin, sum_cos); + + userdata->resize_init = (resize_init_t*) ALLOC((size_t)sel_n * sizeof(resize_init_t)); + userdata->resize_init_count = sel_n; + int j = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected) { + float sc = cosf(s->rotation), ss = sinf(s->rotation); + float hlx = (userdata->resize_start_wx - s->cx) * sc + (userdata->resize_start_wy - s->cy) * ss; + float hly = -(userdata->resize_start_wx - s->cx) * ss + (userdata->resize_start_wy - s->cy) * sc; + float plx = (userdata->resize_pivot_x - s->cx) * sc + (userdata->resize_pivot_y - s->cy) * ss; + float ply = -(userdata->resize_pivot_x - s->cx) * ss + (userdata->resize_pivot_y - s->cy) * sc; + + userdata->resize_init[j].idx = i; + userdata->resize_init[j].init_sx = s->sx; + userdata->resize_init[j].init_sy = s->sy; + userdata->resize_init[j].init_cx = s->cx; + userdata->resize_init[j].init_cy = s->cy; + userdata->resize_init[j].ext_x = hlx - plx; + userdata->resize_init[j].ext_y = hly - ply; + userdata->resize_init[j].lpi_x = plx; + userdata->resize_init[j].lpi_y = ply; + j++; + } + } + } + } + + if (resize_hit < 0) { + float grip = HANDLE_RADIUS_PX / userdata->zoom + tol; + float dcx = wx - userdata->rotate_center_x; + float dcy = wy - userdata->rotate_center_y; + float dist = sqrtf(dcx * dcx + dcy * dcy); + bool on_handle = (userdata->selected_count > 0) && + (fabsf(dist - userdata->handle_radius) <= grip); + + if (on_handle) { + userdata->rotate_dragging = true; + userdata->rotate_start_angle = atan2f( + wy - userdata->rotate_center_y, + wx - userdata->rotate_center_x); + userdata->rotate_total_delta = 0.0f; + } else { + int clicked_selected = -1; + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected && shape_hit_test(s, wx, wy, tol)) { + clicked_selected = i; + break; + } + } + + bool in_aabb = false; + if (clicked_selected < 0 && userdata->selected_count >= 2) { + float omin[2], omax[2]; + if (userdata->aabb_cached) { + omin[0] = userdata->cached_aabb[0]; omin[1] = userdata->cached_aabb[1]; + omax[0] = userdata->cached_aabb[2]; omax[1] = userdata->cached_aabb[3]; + } else { + selected_aabb(userdata, &omin[0], &omin[1], &omax[0], &omax[1]); + } + float pad = 8.0f / userdata->zoom; + omin[0] -= pad; omin[1] -= pad; + omax[0] += pad; omax[1] += pad; + in_aabb = (wx >= omin[0] && wx <= omax[0] && wy >= omin[1] && wy <= omax[1]); + } + + if (clicked_selected >= 0 || in_aabb) { + userdata->move_dragging = true; + userdata->move_start_wx = wx; + userdata->move_start_wy = wy; + userdata->move_total_dx = 0; + userdata->move_total_dy = 0; + } else { + userdata->selecting = true; + userdata->sel_dragging = false; + userdata->sel_sx = event->mouse_x; + userdata->sel_sy = event->mouse_y; + userdata->sel_cx = event->mouse_x; + userdata->sel_cy = event->mouse_y; + + userdata->sel_clicked_shape = -1; + for (int i = 0; i < userdata->shapes.count; i++) { + if (shape_hit_test((shape_t*) vec_get(&userdata->shapes, i), wx, wy, tol)) { + userdata->sel_clicked_shape = i; + break; + } + } + } + } } } @@ -534,7 +1088,143 @@ static void event(const sapp_event* event, void* _userdata) break; case SAPP_EVENTTYPE_MOUSE_UP: - if (userdata->selecting) { + if (userdata->resize_dragging) { + int n = userdata->resize_init_count; + bool changed = false; + for (int j = 0; j < n; j++) { + resize_init_t *ini = &userdata->resize_init[j]; + shape_t *s = (shape_t*) vec_get(&userdata->shapes, ini->idx); + if (s->sx != ini->init_sx || s->sy != ini->init_sy || + s->cx != ini->init_cx || s->cy != ini->init_cy) + changed = true; + } + if (changed) { + hist_entry_t entry = { .changes = NULL, .count = n * 2 }; + entry.changes = (hist_change_t*) ALLOC((size_t)n * 2 * sizeof(hist_change_t)); + + for (int j = 0; j < n; j++) { + resize_init_t *ini = &userdata->resize_init[j]; + shape_t *s = (shape_t*) vec_get(&userdata->shapes, ini->idx); + entry.changes[j*2] = (hist_change_t){ + .shape_index = ini->idx, + .prop = HIST_POSITION, + .old_val = { ini->init_cx, ini->init_cy }, + .new_val = { s->cx, s->cy }, + }; + entry.changes[j*2+1] = (hist_change_t){ + .shape_index = ini->idx, + .prop = HIST_SCALE, + .old_val = { ini->init_sx, ini->init_sy }, + .new_val = { s->sx, s->sy }, + }; + } + + history_push_entry(&userdata->history, entry); + } + + FREE(userdata->resize_init); + userdata->resize_init = NULL; + userdata->resize_init_count = 0; + userdata->resize_dragging = false; + + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + shape_set_state(s, s->hovered, s->selected); + } + spatial_mark_dirty(&userdata->spatial_grid); + userdata->aabb_cached = false; + userdata->overlay_upload_needed = true; + } + else if (userdata->rotate_dragging) { + if (userdata->rotate_total_delta != 0.0f) { + int sel_count = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + if (((shape_t*) vec_get(&userdata->shapes, i))->selected) sel_count++; + } + + float cos_b = cosf(-userdata->rotate_total_delta); + float sin_b = sinf(-userdata->rotate_total_delta); + float cx = userdata->rotate_center_x; + float cy = userdata->rotate_center_y; + + hist_entry_t entry = { .changes = NULL, .count = sel_count * 2 }; + entry.changes = (hist_change_t*) ALLOC(sel_count * 2 * sizeof(hist_change_t)); + + int idx = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected) { + float dx = s->cx - cx; + float dy = s->cy - cy; + float old_cx = cx + dx * cos_b - dy * sin_b; + float old_cy = cy + dx * sin_b + dy * cos_b; + + entry.changes[idx++] = (hist_change_t){ + .shape_index = i, + .prop = HIST_POSITION, + .old_val = { old_cx, old_cy }, + .new_val = { s->cx, s->cy }, + }; + entry.changes[idx++] = (hist_change_t){ + .shape_index = i, + .prop = HIST_ROTATION, + .old_val = { s->rotation - userdata->rotate_total_delta }, + .new_val = { s->rotation }, + }; + } + } + + history_push_entry(&userdata->history, entry); + } + + userdata->rotate_dragging = false; + + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + shape_set_state(s, s->hovered, s->selected); + } + spatial_mark_dirty(&userdata->spatial_grid); + userdata->aabb_cached = false; + userdata->overlay_upload_needed = true; + } + else if (userdata->move_dragging) { + if (userdata->move_total_dx != 0.0f || userdata->move_total_dy != 0.0f) { + int sel_count = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + if (((shape_t*) vec_get(&userdata->shapes, i))->selected) sel_count++; + } + + hist_entry_t entry = { .changes = NULL, .count = sel_count }; + entry.changes = (hist_change_t*) ALLOC(sel_count * sizeof(hist_change_t)); + + int idx = 0; + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected) { + entry.changes[idx] = (hist_change_t){ + .shape_index = i, + .prop = HIST_POSITION, + .old_val = { s->cx - userdata->move_total_dx, s->cy - userdata->move_total_dy }, + .new_val = { s->cx, s->cy }, + }; + idx++; + } + } + + history_push_entry(&userdata->history, entry); + } + + userdata->move_dragging = false; + + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + shape_set_state(s, s->hovered, s->selected); + } + spatial_mark_dirty(&userdata->spatial_grid); + userdata->aabb_cached = false; + userdata->overlay_upload_needed = true; + } + else if (userdata->selecting) { if (!userdata->sel_dragging) { if (userdata->sel_clicked_shape >= 0) { for (int i = 0; i < userdata->shapes.count; i++) { @@ -559,6 +1249,7 @@ static void event(const sapp_event* event, void* _userdata) shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); shape_set_state(s, s->hovered, s->selected); } + userdata->overlay_upload_needed = true; } userdata->dragger.dragging = false; @@ -572,6 +1263,98 @@ static void event(const sapp_event* event, void* _userdata) compute_mvp(userdata); } + else if (userdata->resize_dragging) + { + float wx, wy; + screen_to_world(userdata, event->mouse_x, event->mouse_y, &wx, &wy); + + float sx_total = 1.0f, sy_total = 1.0f; + + for (int j = 0; j < userdata->resize_init_count; j++) { + resize_init_t *ini = &userdata->resize_init[j]; + shape_t *s = (shape_t*) vec_get(&userdata->shapes, ini->idx); + + float sc = cosf(s->rotation), ss = sinf(s->rotation); + float mlx = (wx - ini->init_cx) * sc + (wy - ini->init_cy) * ss; + float mly = -(wx - ini->init_cx) * ss + (wy - ini->init_cy) * sc; + + float cex = mlx - ini->lpi_x; + float cey = mly - ini->lpi_y; + + float scale_x = 1.0f, scale_y = 1.0f; + if (userdata->resize_mask_x && fabsf(ini->ext_x) >= 0.0001f) + scale_x = fabsf(cex / ini->ext_x); + if (userdata->resize_mask_y && fabsf(ini->ext_y) >= 0.0001f) + scale_y = fabsf(cey / ini->ext_y); + + s->sx = ini->init_sx * scale_x; + s->sy = ini->init_sy * scale_y; + s->cx = ini->init_cx - ini->lpi_x * (scale_x - 1.0f) * sc + ini->lpi_y * (scale_y - 1.0f) * ss; + s->cy = ini->init_cy - ini->lpi_x * (scale_x - 1.0f) * ss - ini->lpi_y * (scale_y - 1.0f) * sc; + + shape_regenerate(s); + shape_set_state(s, false, true); + + sx_total = scale_x; + sy_total = scale_y; + } + + userdata->resize_total_scale_x = sx_total; + userdata->resize_total_scale_y = sy_total; + } + else if (userdata->rotate_dragging) + { + float wx, wy; + screen_to_world(userdata, event->mouse_x, event->mouse_y, &wx, &wy); + float angle = atan2f(wy - userdata->rotate_center_y, + wx - userdata->rotate_center_x); + float delta = angle - userdata->rotate_start_angle; + if (delta > GLM_PIf) delta -= 2.0f * GLM_PIf; + else if (delta < -GLM_PIf) delta += 2.0f * GLM_PIf; + float inc = delta - userdata->rotate_total_delta; + + float cos_a = cosf(inc); + float sin_a = sinf(inc); + float cx = userdata->rotate_center_x; + float cy = userdata->rotate_center_y; + + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected) { + float dx = s->cx - cx; + float dy = s->cy - cy; + s->cx = cx + dx * cos_a - dy * sin_a; + s->cy = cy + dx * sin_a + dy * cos_a; + s->rotation += inc; + shape_build_transform(s); + shape_set_state(s, false, true); + } + } + + userdata->rotate_total_delta = delta; + } + else if (userdata->move_dragging) + { + float wx, wy; + screen_to_world(userdata, event->mouse_x, event->mouse_y, &wx, &wy); + float dx = wx - userdata->move_start_wx; + float dy = wy - userdata->move_start_wy; + float delta_x = dx - userdata->move_total_dx; + float delta_y = dy - userdata->move_total_dy; + + for (int i = 0; i < userdata->shapes.count; i++) { + shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); + if (s->selected) { + s->cx += delta_x; + s->cy += delta_y; + shape_build_transform(s); + shape_set_state(s, false, true); + } + } + + userdata->move_total_dx = dx; + userdata->move_total_dy = dy; + } else if (userdata->selecting) { userdata->sel_cx = event->mouse_x; @@ -589,27 +1372,9 @@ static void event(const sapp_event* event, void* _userdata) float min_x = fminf(wx1, wx2), min_y = fminf(wy1, wy2); float max_x = fmaxf(wx1, wx2), max_y = fmaxf(wy1, wy2); - for (int i = 0; i < userdata->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); - s->selected = false; - } - userdata->selected_count = 0; - - for (int i = 0; i < userdata->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); - bool hit = false; - if (s->cx >= min_x && s->cx <= max_x && s->cy >= min_y && s->cy <= max_y) - hit = true; - for (uint32_t v = 0; !hit && v < s->num_verts; v++) { - if (s->verts[v].x >= min_x && s->verts[v].x <= max_x && - s->verts[v].y >= min_y && s->verts[v].y <= max_y) - hit = true; - } - if (hit) { - s->selected = true; - userdata->selected_count++; - } - } + userdata->selected_count = spatial_query_rect_select( + &userdata->spatial_grid, &userdata->shapes, + min_x, min_y, max_x, max_y); for (int i = 0; i < userdata->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); @@ -621,21 +1386,13 @@ static void event(const sapp_event* event, void* _userdata) { float wx, wy; screen_to_world(userdata, event->mouse_x, event->mouse_y, &wx, &wy); - const float tol = SHAPE_HOVER_PX / userdata->zoom; + const float tol = userdata->hover_tol; - int hovered = -1; - for (int i = 0; i < userdata->shapes.count; i++) { - if (shape_hit_test((shape_t*) vec_get(&userdata->shapes, i), wx, wy, tol)) { - hovered = i; - break; - } - } + int hovered = spatial_query_point(&userdata->spatial_grid, + &userdata->shapes, wx, wy, tol); if (hovered != userdata->hovered_shape) { userdata->hovered_shape = hovered; - emscripten_run_script( - hovered >= 0 - ? "document.querySelector('canvas').style.cursor = 'pointer'" - : "document.querySelector('canvas').style.cursor = 'default'"); + EM_ASM({ document.querySelector('canvas').style.cursor = $0 ? 'pointer' : 'default'; }, hovered >= 0); } for (int i = 0; i < userdata->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&userdata->shapes, i); @@ -653,12 +1410,14 @@ static void event(const sapp_event* event, void* _userdata) const float diff = expf(event->scroll_y * 0.1f); userdata->zoom = _sg_clamp(userdata->zoom * diff, 0.1f, 6.0f); + userdata->hover_tol = SHAPE_HOVER_PX / userdata->zoom; - const float sx = event->mouse_x - userdata->width * 0.5f; - const float sy = userdata->height * 0.5f - event->mouse_y; + const float sx = event->mouse_x - userdata->half_width; + const float sy = userdata->half_height - event->mouse_y; userdata->pan[0] = sx - wx * userdata->zoom; userdata->pan[1] = sy - wy * userdata->zoom; + userdata->overlay_upload_needed = true; compute_mvp(userdata); } break; default: @@ -666,6 +1425,14 @@ static void event(const sapp_event* event, void* _userdata) } } +/** + * Application entry point. Allocates userdata and returns the sokol app + * descriptor wiring init, frame, cleanup, and event callbacks. + * + * @param argc argument count (unused) + * @param argv argument vector (unused) + * @return sokol application descriptor + */ sapp_desc sokol_main(int argc, char* argv[]) { userdata_t* userdata = (userdata_t*) ALLOC(sizeof(userdata_t)); diff --git a/src/rand.h b/src/rand.h index a48d53f..7a4abe5 100644 --- a/src/rand.h +++ b/src/rand.h @@ -14,6 +14,11 @@ static float next_float(void); static float next_float_max(float max); static float next_float_minmax(float min, float max); +/** + * Xorshift32 PRNG core. Advances the global seed and returns the new value. + * + * @return pseudo-random 32-bit integer + */ static uint32_t xorshift32(void) { seed ^= seed<<13; @@ -21,6 +26,12 @@ static uint32_t xorshift32(void) seed ^= seed<<5; return seed; } +/** + * Seed the global PRNG state. Zero is ignored (caller should pass a non-zero + * seed). Runs the generator once after seeding to mix the state. + * + * @param _seed non-zero 32-bit seed value + */ static void rand_seed(uint32_t _seed) { if(_seed == 0) @@ -29,34 +40,64 @@ static void rand_seed(uint32_t _seed) seed = _seed; xorshift32(); } -// PRNG [0-UINT32_MAX] +/** + * Return a random integer in [0, UINT32_MAX]. + * + * @return pseudo-random 32-bit integer + */ static uint32_t next_int(void) { return xorshift32(); } -// PRNG [0-max] +/** + * Return a random integer in [0, max]. + * + * @param max inclusive upper bound + * @return pseudo-random integer + */ static uint32_t next_int_max(uint32_t max) { return (uint32_t) floorf(xorshift32() / (float) UINT32_MAX * max); } -// PRNG [min-max] +/** + * Return a random integer in [min, max]. + * + * @param min inclusive lower bound + * @param max inclusive upper bound + * @return pseudo-random integer + */ static uint32_t next_int_minmax(uint32_t min, uint32_t max) { const float x = (float) xorshift32() / UINT32_MAX; //(1.0f - Time) * A + Time * B return (1.0f - x) * min + x * max; } -// PRNG [0-1] +/** + * Return a random float in [0, 1]. + * + * @return pseudo-random float + */ static float next_float(void) { return (float) xorshift32() / UINT32_MAX; } -// PRNG [0-max] +/** + * Return a random float in [0, max]. + * + * @param max inclusive upper bound + * @return pseudo-random float + */ static float next_float_max(float max) { return (float) xorshift32() / UINT32_MAX * max; } -// PRNG [min-max] +/** + * Return a random float in [min, max]. + * + * @param min inclusive lower bound + * @param max inclusive upper bound + * @return pseudo-random float + */ static float next_float_minmax(float min, float max) { const float x = (float) xorshift32() / UINT32_MAX; diff --git a/src/shape.h b/src/shape.h index c15c730..c17b914 100644 --- a/src/shape.h +++ b/src/shape.h @@ -36,6 +36,7 @@ typedef struct shape_t { float rotation; int star_points; float star_inner_ratio; + int last_update_frame; } shape_t; #define SHAPE_HOVER_PX 6.0f @@ -43,7 +44,17 @@ typedef struct shape_t { static sg_pipeline shape_pipeline; static sg_pipeline overlay_pipeline; static sg_shader shape_shader; +static int g_shape_frame_id; +static void shape_begin_frame(void) +{ + g_shape_frame_id++; +} + +/** + * Create the shape shader, shape pipeline (line strip), and overlay pipeline + * (triangles). Call once during app init before drawing any shapes. + */ static void shape_init_pipeline(void) { shape_shader = sg_make_shader(&(sg_shader_desc) { @@ -94,6 +105,9 @@ static void shape_init_pipeline(void) }); } +/** + * Destroy the shape shader and both pipelines. Call during app shutdown. + */ static void shape_shutdown_pipeline(void) { sg_destroy_pipeline(shape_pipeline); @@ -101,6 +115,13 @@ static void shape_shutdown_pipeline(void) sg_destroy_shader(shape_shader); } +/** + * Return the number of line segments for a circle of the given radius. + * Clamped to [8, 128]; scales roughly with circumference. + * + * @param r circle radius in world units + * @return segment count + */ static int shape_calc_segments(float r) { int n = (int)(fabsf(r) * 0.5f) + 16; @@ -109,22 +130,50 @@ static int shape_calc_segments(float r) return n; } +/** + * Set default state for a newly created shape: identity transform, base color, + * not hovered, not selected. + * + * @param s shape to initialize + * @param color RGBA base color (copied) + */ static void shape_init_common(shape_t *s, const float color[4]) { s->hovered = false; s->selected = false; - glm_mat4_identity(s->uniform.transform); memcpy(s->uniform.base_color, color, sizeof(float[4])); s->uniform.state = 0; memset(s->uniform._pad, 0, sizeof(s->uniform._pad)); } +/** + * Build the per-shape transform matrix from cx, cy, rotation. + * Uses R(-angle) so the shader's row-vector convention matches the existing + * world-space vertex computation. + */ +static void shape_build_transform(shape_t *s) +{ + mat4 T, R, S, RS; + glm_translate_make(T, (vec3){s->cx, s->cy, 0.0f}); + glm_rotate_make(R, -s->rotation, (vec3){0.0f, 0.0f, 1.0f}); + glm_scale_make(S, (vec3){s->sx, s->sy, 1.0f}); + glm_mat4_mul(R, S, RS); + glm_mat4_mul(T, RS, s->uniform.transform); +} + +/** + * Create GPU vertex and index buffers from the shape's current verts/indices. + * + * @param s shape (must have verts and indices allocated) + */ static void shape_make_buffers(shape_t *s) { s->vbuf = sg_make_buffer(&(sg_buffer_desc) { - .data = { s->verts, s->num_indices * sizeof(shape_vertex_t) }, + .size = s->num_indices * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, .label = "Shape vertices", }); + sg_update_buffer(s->vbuf, &(sg_range){s->verts, s->num_indices * sizeof(shape_vertex_t)}); s->ibuf = sg_make_buffer(&(sg_buffer_desc) { .usage = { .index_buffer = true }, .data = { s->indices, s->num_indices * sizeof(uint16_t) }, @@ -132,6 +181,11 @@ static void shape_make_buffers(shape_t *s) }); } +/** + * Destroy GPU buffers and free vertex/index arrays for a single shape. + * + * @param s shape to tear down + */ static void shape_shutdown(shape_t *s) { sg_destroy_buffer(s->vbuf); @@ -140,50 +194,47 @@ static void shape_shutdown(shape_t *s) FREE(s->indices); } +/** + * Rebuild vertex and index data from the shape's current parameters (position, + * scale, rotation, kind), then recreate GPU buffers. Call after any parameter + * change. + * + * @param s shape to regenerate + */ static void shape_regenerate(shape_t *s) { - sg_destroy_buffer(s->vbuf); - sg_destroy_buffer(s->ibuf); - int n, count; if (s->kind == SHAPE_CIRCLE) { int segs = shape_calc_segments(s->sx); n = segs; count = segs + 1; - - if (s->num_indices != (uint32_t)count) { - FREE(s->verts); - FREE(s->indices); - s->verts = (shape_vertex_t*) ALLOC(count * sizeof(shape_vertex_t)); - s->indices = (uint16_t*) ALLOC(count * sizeof(uint16_t)); - } - - for (int i = 0; i < segs; i++) { - float a = (float)i / (float)segs * 2.0f * GLM_PIf - GLM_PI_2f + s->rotation; - s->verts[i] = (shape_vertex_t) { - s->cx + cosf(a) * s->sx, - s->cy + sinf(a) * s->sy, - }; - } - s->verts[segs] = s->verts[0]; } else { n = s->star_points * 2; count = n + 1; + } - if (s->num_indices != (uint32_t)count) { - FREE(s->verts); - FREE(s->indices); - s->verts = (shape_vertex_t*) ALLOC(count * sizeof(shape_vertex_t)); - s->indices = (uint16_t*) ALLOC(count * sizeof(uint16_t)); + bool resized = ((uint32_t)count != s->num_indices); + if (resized) { + sg_destroy_buffer(s->vbuf); + sg_destroy_buffer(s->ibuf); + FREE(s->verts); + FREE(s->indices); + s->verts = (shape_vertex_t*) ALLOC(count * sizeof(shape_vertex_t)); + s->indices = (uint16_t*) ALLOC(count * sizeof(uint16_t)); + } + + if (s->kind == SHAPE_CIRCLE) { + int segs = n; + for (int i = 0; i < segs; i++) { + float a = (float)i / (float)segs * 2.0f * GLM_PIf - GLM_PI_2f; + s->verts[i] = (shape_vertex_t) { cosf(a), sinf(a) }; } - + s->verts[segs] = s->verts[0]; + } else { for (int i = 0; i < n; i++) { - float a = (float)i / (float)n * 2.0f * GLM_PIf - GLM_PI_2f + s->rotation; - float r = (i & 1) ? s->star_inner_ratio * s->sx : s->sx; - s->verts[i] = (shape_vertex_t) { - s->cx + cosf(a) * r, - s->cy + sinf(a) * r, - }; + float a = (float)i / (float)n * 2.0f * GLM_PIf - GLM_PI_2f; + float r = (i & 1) ? s->star_inner_ratio : 1.0f; + s->verts[i] = (shape_vertex_t) { cosf(a) * r, sinf(a) * r }; } s->verts[n] = s->verts[0]; } @@ -192,9 +243,25 @@ static void shape_regenerate(shape_t *s) s->num_verts = (uint32_t)n; for (int i = 0; i <= n; i++) s->indices[i] = (uint16_t)i; - shape_make_buffers(s); + shape_build_transform(s); + + if (resized) { + shape_make_buffers(s); + s->last_update_frame = g_shape_frame_id; + } else if (s->last_update_frame != g_shape_frame_id) { + sg_update_buffer(s->vbuf, &(sg_range){s->verts, (size_t)count * sizeof(shape_vertex_t)}); + s->last_update_frame = g_shape_frame_id; + } } +/** + * Update hovered/selected flags and the shader uniform state. + * State is 0=normal, 1=hovered (brightened), 2=selected (green). + * + * @param s shape to update + * @param hovered true if cursor is over the shape + * @param selected true if shape is in the selection set + */ static void shape_set_state(shape_t *s, bool hovered, bool selected) { s->hovered = hovered; @@ -202,6 +269,16 @@ static void shape_set_state(shape_t *s, bool hovered, bool selected) s->uniform.state = selected ? 2u : (hovered ? 1u : 0u); } +/** + * Ray-casting point-in-polygon test. Handles arbitrary non-self-intersecting + * polygons. + * + * @param px point X in world space + * @param py point Y in world space + * @param verts polygon vertices + * @param n vertex count + * @return true if the point is inside the polygon + */ static bool point_in_polygon(float px, float py, shape_vertex_t *verts, uint32_t n) { bool inside = false; @@ -214,11 +291,28 @@ static bool point_in_polygon(float px, float py, shape_vertex_t *verts, uint32_t return inside; } +/** + * Test whether a world-space point hits this shape. Transforms the query + * to local space (verts are now stored relative to origin), then tests + * polygon containment and edge proximity. + * + * @param s shape to test + * @param wx point X in world space + * @param wy point Y in world space + * @param world_tol hit tolerance in world units + * @return true if the point hits the shape + */ static bool shape_hit_test(shape_t *s, float wx, float wy, float world_tol) { - float tol_sq = world_tol * world_tol; + float sc = cosf(s->rotation), ss = sinf(s->rotation); + float dx = wx - s->cx, dy = wy - s->cy; + float lx = (dx * sc + dy * ss) / s->sx; + float ly = (-dx * ss + dy * sc) / s->sy; + float min_scale = fminf(fabsf(s->sx), fabsf(s->sy)); + float local_tol = world_tol / (min_scale > 0.0001f ? min_scale : 1.0f); + float tol_sq = local_tol * local_tol; - if (point_in_polygon(wx, wy, s->verts, s->num_verts)) + if (point_in_polygon(lx, ly, s->verts, s->num_verts)) return true; for (uint32_t i = 0, j = s->num_verts - 1; i < s->num_verts; j = i++) { @@ -227,18 +321,23 @@ static bool shape_hit_test(shape_t *s, float wx, float wy, float world_tol) float abx = bx - ax, aby = by - ay; float len_sq = abx * abx + aby * aby; if (len_sq < 0.0001f) continue; - float t = ((wx - ax) * abx + (wy - ay) * aby) / len_sq; + float t = ((lx - ax) * abx + (ly - ay) * aby) / len_sq; t = fmaxf(0.0f, fminf(1.0f, t)); float cx = ax + t * abx, cy = ay + t * aby; - float dx = wx - cx, dy = wy - cy; - if (dx * dx + dy * dy <= tol_sq) return true; + float ddx = lx - cx, ddy = ly - cy; + if (ddx * ddx + ddy * ddy <= tol_sq) return true; } return false; } +/** + * Issue a draw call for this shape using the shape line-strip pipeline. + * + * @param s shape to draw + * @param mvp model-view-projection matrix (from compute_mvp) + */ static void shape_draw(shape_t *s, const mat4 *mvp) { - sg_apply_pipeline(shape_pipeline); sg_apply_uniforms(0, &SG_RANGE(*mvp)); sg_apply_uniforms(1, &SG_RANGE(s->uniform)); sg_apply_bindings(&(sg_bindings) { @@ -248,6 +347,16 @@ static void shape_draw(shape_t *s, const mat4 *mvp) sg_draw(0, s->num_indices, 1); } +/** + * Create a circle shape (returned by value). Allocates verts/indices and GPU + * buffers. The number of line segments adapts to radius. + * + * @param x center X in world space + * @param y center Y in world space + * @param r radius in world units + * @param color RGBA base color + * @return fully initialized shape_t + */ static shape_t shape_circle(float x, float y, float r, const float color[4]) { shape_t s; @@ -263,10 +372,7 @@ static shape_t shape_circle(float x, float y, float r, const float color[4]) for (int i = 0; i < segs; i++) { float a = (float)i / (float)segs * 2.0f * GLM_PIf - GLM_PI_2f; - s.verts[i] = (shape_vertex_t) { - x + cosf(a) * r, - y + sinf(a) * r, - }; + s.verts[i] = (shape_vertex_t) { cosf(a), sinf(a) }; } s.verts[segs] = s.verts[0]; for (int i = 0; i <= segs; i++) s.indices[i] = (uint16_t)i; @@ -274,10 +380,24 @@ static shape_t shape_circle(float x, float y, float r, const float color[4]) s.num_verts = (uint32_t)segs; shape_init_common(&s, color); + shape_build_transform(&s); shape_make_buffers(&s); return s; } +/** + * Create a star shape (returned by value). Alternates between outer_r and + * inner_r at each vertex, producing a star with the given number of points. + * Allocates verts/indices and GPU buffers. + * + * @param x center X in world space + * @param y center Y in world space + * @param outer_r outer radius in world units + * @param inner_r inner radius in world units + * @param points number of star points + * @param color RGBA base color + * @return fully initialized shape_t + */ static shape_t shape_star(float x, float y, float outer_r, float inner_r, int points, const float color[4]) { @@ -296,11 +416,8 @@ static shape_t shape_star(float x, float y, float outer_r, float inner_r, for (int i = 0; i < n; i++) { float a = (float)i / (float)n * 2.0f * GLM_PIf - GLM_PI_2f; - float r = (i & 1) ? inner_r : outer_r; - s.verts[i] = (shape_vertex_t) { - x + cosf(a) * r, - y + sinf(a) * r, - }; + float r = (i & 1) ? s.star_inner_ratio : 1.0f; + s.verts[i] = (shape_vertex_t) { cosf(a) * r, sinf(a) * r }; } s.verts[n] = s.verts[0]; for (int i = 0; i <= n; i++) s.indices[i] = (uint16_t)i; @@ -308,6 +425,7 @@ static shape_t shape_star(float x, float y, float outer_r, float inner_r, s.num_verts = (uint32_t)n; shape_init_common(&s, color); + shape_build_transform(&s); shape_make_buffers(&s); return s; } diff --git a/src/spatial.h b/src/spatial.h new file mode 100644 index 0000000..bcc9f6b --- /dev/null +++ b/src/spatial.h @@ -0,0 +1,233 @@ +#ifndef SPATIAL_H +#define SPATIAL_H + +#include "api.h" + +// Tunable constants +#define SPATIAL_CELL_SIZE 250.0f +#define SPATIAL_HASH_BITS 8 +#define SPATIAL_HASH_SIZE (1 << SPATIAL_HASH_BITS) +#define SPATIAL_QUERY_RANGE 1 + +typedef struct { + int shape_idx; + float min_x, min_y, max_x, max_y; +} spatial_entry_t; + +typedef struct { + bool occupied; + int cx, cy; + spatial_entry_t *entries; + int count; + int capacity; +} spatial_slot_t; + +typedef struct { + spatial_slot_t slots[SPATIAL_HASH_SIZE]; + bool dirty; +} spatial_grid_t; + +static int spatial_hash(int cx, int cy) +{ + return (cx * 73856093) ^ (cy * 19349663); +} + +static void spatial_compute_aabb(shape_t *s, float *min_x, float *min_y, + float *max_x, float *max_y) +{ + float cos_r = cosf(s->rotation); + float sin_r = sinf(s->rotation); + float hx = fabsf(cos_r) * s->sx + fabsf(sin_r) * s->sy; + float hy = fabsf(sin_r) * s->sx + fabsf(cos_r) * s->sy; + *min_x = s->cx - hx; + *min_y = s->cy - hy; + *max_x = s->cx + hx; + *max_y = s->cy + hy; +} + +static void spatial_init(spatial_grid_t *grid) +{ + memset(grid, 0, sizeof(*grid)); + grid->dirty = true; +} + +static void spatial_mark_dirty(spatial_grid_t *grid) +{ + grid->dirty = true; +} + +static void spatial_destroy(spatial_grid_t *grid) +{ + for (int i = 0; i < SPATIAL_HASH_SIZE; i++) { + if (grid->slots[i].entries) FREE(grid->slots[i].entries); + } + memset(grid, 0, sizeof(*grid)); +} + +static void spatial_rebuild(spatial_grid_t *grid, vector_t *shapes) +{ + if (!grid->dirty) return; + grid->dirty = false; + + int n = shapes->count; + + // Phase 0: clear occupied flags + for (int i = 0; i < SPATIAL_HASH_SIZE; i++) { + grid->slots[i].occupied = false; + grid->slots[i].count = 0; + } + + if (n == 0) return; + + // Phase 1: count shapes per cell + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(shapes, i); + int ccx = (int) floorf(s->cx / SPATIAL_CELL_SIZE); + int ccy = (int) floorf(s->cy / SPATIAL_CELL_SIZE); + + int idx = spatial_hash(ccx, ccy) & (SPATIAL_HASH_SIZE - 1); + while (grid->slots[idx].occupied) { + if (grid->slots[idx].cx == ccx && grid->slots[idx].cy == ccy) break; + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + } + + if (!grid->slots[idx].occupied) { + grid->slots[idx].occupied = true; + grid->slots[idx].cx = ccx; + grid->slots[idx].cy = ccy; + } + grid->slots[idx].count++; + } + + // Phase 2: allocate entry arrays based on count + for (int i = 0; i < SPATIAL_HASH_SIZE; i++) { + if (!grid->slots[i].occupied) continue; + if (grid->slots[i].count > grid->slots[i].capacity) { + if (grid->slots[i].entries) FREE(grid->slots[i].entries); + grid->slots[i].entries = (spatial_entry_t*) ALLOC( + (size_t) grid->slots[i].count * sizeof(spatial_entry_t)); + grid->slots[i].capacity = grid->slots[i].count; + } + grid->slots[i].count = 0; // reset for fill phase + } + + // Phase 3: fill entries + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(shapes, i); + int ccx = (int) floorf(s->cx / SPATIAL_CELL_SIZE); + int ccy = (int) floorf(s->cy / SPATIAL_CELL_SIZE); + + int idx = spatial_hash(ccx, ccy) & (SPATIAL_HASH_SIZE - 1); + while (!(grid->slots[idx].occupied && + grid->slots[idx].cx == ccx && grid->slots[idx].cy == ccy)) { + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + } + + spatial_entry_t *e = &grid->slots[idx].entries[grid->slots[idx].count++]; + e->shape_idx = i; + spatial_compute_aabb(s, &e->min_x, &e->min_y, &e->max_x, &e->max_y); + } +} + +static int spatial_query_point(spatial_grid_t *grid, vector_t *shapes, + float wx, float wy, float world_tol) +{ + int ccx = (int) floorf(wx / SPATIAL_CELL_SIZE); + int ccy = (int) floorf(wy / SPATIAL_CELL_SIZE); + + for (int dz = -SPATIAL_QUERY_RANGE; dz <= SPATIAL_QUERY_RANGE; dz++) { + for (int dw = -SPATIAL_QUERY_RANGE; dw <= SPATIAL_QUERY_RANGE; dw++) { + int cell_x = ccx + dz; + int cell_y = ccy + dw; + + int idx = spatial_hash(cell_x, cell_y) & (SPATIAL_HASH_SIZE - 1); + int probe_start = idx; + + do { + if (!grid->slots[idx].occupied) break; + + if (grid->slots[idx].cx == cell_x && grid->slots[idx].cy == cell_y) { + for (int e = 0; e < grid->slots[idx].count; e++) { + spatial_entry_t *entry = &grid->slots[idx].entries[e]; + + if (wx < entry->min_x - world_tol || + wx > entry->max_x + world_tol || + wy < entry->min_y - world_tol || + wy > entry->max_y + world_tol) + continue; + + shape_t *s = (shape_t*) vec_get(shapes, entry->shape_idx); + if (shape_hit_test(s, wx, wy, world_tol)) + return entry->shape_idx; + } + break; + } + + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + } while (idx != probe_start); + } + } + return -1; +} + +static int spatial_query_rect_select(spatial_grid_t *grid, vector_t *shapes, + float min_x, float min_y, + float max_x, float max_y) +{ + for (int i = 0; i < shapes->count; i++) { + ((shape_t*) vec_get(shapes, i))->selected = false; + } + int selected_count = 0; + + int min_cx = (int) floorf(min_x / SPATIAL_CELL_SIZE); + int min_cy = (int) floorf(min_y / SPATIAL_CELL_SIZE); + int max_cx = (int) floorf(max_x / SPATIAL_CELL_SIZE); + int max_cy = (int) floorf(max_y / SPATIAL_CELL_SIZE); + + for (int cell_x = min_cx; cell_x <= max_cx; cell_x++) { + for (int cell_y = min_cy; cell_y <= max_cy; cell_y++) { + int idx = spatial_hash(cell_x, cell_y) & (SPATIAL_HASH_SIZE - 1); + int probe_start = idx; + + do { + if (!grid->slots[idx].occupied) break; + + if (grid->slots[idx].cx == cell_x && grid->slots[idx].cy == cell_y) { + for (int e = 0; e < grid->slots[idx].count; e++) { + spatial_entry_t *entry = &grid->slots[idx].entries[e]; + + if (entry->max_x < min_x || entry->min_x > max_x || + entry->max_y < min_y || entry->min_y > max_y) + continue; + + shape_t *s = (shape_t*) vec_get(shapes, entry->shape_idx); + if (s->selected) continue; + + bool hit = (s->cx >= min_x && s->cx <= max_x && + s->cy >= min_y && s->cy <= max_y); + float sc = cosf(s->rotation), ss = sinf(s->rotation); + for (uint32_t v = 0; !hit && v < s->num_verts; v++) { + float lx = s->verts[v].x * s->sx; + float ly = s->verts[v].y * s->sy; + float wx = s->cx + lx * sc - ly * ss; + float wy = s->cy + lx * ss + ly * sc; + if (wx >= min_x && wx <= max_x && + wy >= min_y && wy <= max_y) + hit = true; + } + if (hit) { + s->selected = true; + selected_count++; + } + } + break; + } + + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + } while (idx != probe_start); + } + } + return selected_count; +} + +#endif diff --git a/src/util.h b/src/util.h index 5342dd7..8ef4324 100644 --- a/src/util.h +++ b/src/util.h @@ -11,11 +11,24 @@ typedef struct vector_t { int stride; } vector_t; +/** + * Zero-initialize a vector with the given element stride. + * + * @param v vector to initialize + * @param stride byte size of each element + */ static void vec_init(vector_t *v, int stride) { memset(v, 0, sizeof(*v)); v->stride = stride; } +/** + * Grow the vector's backing array to at least min_capacity elements. + * Doubles capacity (starting at 8) or uses min_capacity, whichever is larger. + * + * @param v vector to grow + * @param min_capacity minimum element count required + */ static void vec_grow(vector_t *v, int min_capacity) { int new_cap = v->capacity ? v->capacity * 2 : 8; if (new_cap < min_capacity) new_cap = min_capacity; @@ -28,15 +41,33 @@ static void vec_grow(vector_t *v, int min_capacity) { v->capacity = new_cap; } +/** + * Append an uninitialized element to the end of the vector. Grows if needed. + * + * @param v vector to push into + * @return pointer to the new (uninitialized) element + */ static void *vec_push(vector_t *v) { if (v->count >= v->capacity) vec_grow(v, v->count + 1); return v->data + (v->count++) * v->stride; } +/** + * Remove the last element from the vector (decrements count, no free). + * + * @param v vector to pop from + */ static void vec_pop(vector_t *v) { if (v->count > 0) v->count--; } +/** + * Remove the element at index by swapping in the last element (O(1)). + * Order is not preserved. + * + * @param v vector to remove from + * @param index index of the element to remove + */ static void vec_remove(vector_t *v, int index) { if (index < 0 || index >= v->count) return; if (index < v->count - 1) { @@ -47,10 +78,22 @@ static void vec_remove(vector_t *v, int index) { v->count--; } +/** + * Return a pointer to the element at index (no bounds check). + * + * @param v vector to access + * @param index element index + * @return pointer to the element + */ static void *vec_get(vector_t *v, int index) { return v->data + index * v->stride; } +/** + * Free the backing array and reset the vector to empty. + * + * @param v vector to free + */ static void vec_free(vector_t *v) { if (v->data) FREE(v->data); v->data = NULL;