From 7e3da1c424faceb5e077af8ccfee3ed461ee45b1 Mon Sep 17 00:00:00 2001 From: Peaceultime Date: Sun, 3 May 2026 00:38:45 +0200 Subject: [PATCH] refactor: eliminate globals, add pen tool + edit mode, frustum culling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all file-scope mutable state so the codebase compiles cleanly as a single translation unit. State moved into structs owned by userdata_t: - group_index_ctx_t replaces g_group_by_id/g_group_by_id_cap - shape_pool_ctx_t replaces g_shape_pool_dirty/g_shape_data_dirty and g_shape_groups/g_shape_groups_count - panel_log_ctx_t wired through all subsystems explicitly - pipeline_ctx_t owns render pipeline handles - overlay_upload_state_t (per-buffer flags) replaces single bool New features piggybacking on the refactor: - Pen tool: click-to-place anchors, Catmull-Rom preview, finalize into Bezier shapes with control points - Edit mode: anchor/handle hit testing, dragging, pre-drag undo snapshots, dedicated GPU buffers for edit overlays - Frustum culling: spatial-grid-based viewport cull in draw_shapes with linear-scan fallback for oversized viewports - Log dedup: FNV-1a 64-bit hash to skip duplicate messages - Buffer shrink: halve draw buffers after 60 frames of low usage - Shape geometry hashing for instanced-draw vertex-buffer grouping - Group member_indices arrays with O(n) rebuild - Log ring expanded 64→256 entries, added log_filter Debug build: added --profiling-funcs and -sASSERTIONS flags. --- README.md | 78 +- diff_output.patch | 1831 --------------------------------------------- makefile | 3 +- src/api.h | 27 +- src/camera.h | 33 +- src/draw.h | 338 +++++++-- src/globals.h | 14 + src/history.h | 303 ++++++-- src/input.h | 747 ++++++++++-------- src/interact.h | 98 +-- src/main.c | 123 ++- src/overlay.h | 83 +- src/rand.h | 82 +- src/render.h | 54 +- src/shape.h | 1005 +++++++++++++++++++------ src/spatial.h | 339 ++++++--- src/types.h | 70 +- src/ui_panels.h | 302 +++++--- src/util.h | 25 +- 19 files changed, 2610 insertions(+), 2945 deletions(-) delete mode 100644 diff_output.patch create mode 100644 src/globals.h diff --git a/README.md b/README.md index f8e488d..ed8c65b 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,17 @@ A browser-based world map creation tool inspired by Wonderdraft and Inkarnate. U ## Features -- **Shapes** — procedural circles and stars with per-instance transforms (position, scale, rotation) +- **Shapes** — parametric circles, stars, rectangles; freeform pen tool with Catmull-Rom splines +- **Instanced rendering** — shapes with identical local-space geometry share vertex buffers; per-instance transforms (position, scale, rotation) uploaded via SSBO +- **Bezier editing** — double-click any shape to edit its Bezier control points and handles in local space - **Shape editing** — select, move, rotate, scale individual shapes; rect-select multiple shapes -- **Undo/redo** — property-level history stack (position, scale, rotation, color) with edit session capture and batch operations -- **Spatial index** — hash grid for fast hit testing and rect-selection queries on large shape counts -- **Viewport** — zoom and pan with screen↔world coordinate transforms +- **Groups** — group/ungroup shapes with nested hierarchy support, group-level selection and focus mode +- **Clipboard** — copy/paste shapes with deep-copy of geometry, group remapping for pasted items +- **Undo/redo** — property-level history stack (position, scale, rotation, vertex edits, create/delete, group) with batch operations +- **Pen tool** — click to place control points; Enter, double-click, or close-to-start to commit; Escape to cancel +- **Spatial index** — open-addressing hash grid for fast hit testing, rect-selection, and viewport culling on large shape counts +- **Viewport** — zoom (scroll) and pan (right-click drag) with screen↔world coordinate transforms +- **Debug panel** — toggleable log overlay (backtick), FPS meter with 60-frame rolling average, log filtering ## Tech stack @@ -19,7 +25,7 @@ A browser-based world map creation tool inspired by Wonderdraft and Inkarnate. U | Graphics | [Sokol](https://github.com/floooh/sokol) with WebGPU backend | | UI | [Dear ImGui](https://github.com/ocornut/imgui) via [cimgui](https://github.com/cimgui/cimgui) | | Math | [cglm](https://github.com/recp/cglm) | -| Shaders | WGSL (compiled to C headers via `xxd`) | +| Shaders | WGSL (compiled to C headers via `xxd -i`) | ## Build @@ -40,24 +46,62 @@ Output is `app.html`, served by Emscripten's built-in web server or any static s ``` src/ - main.c Entry point, sokol init, render loop, input handling, UI panels, debug stats - api.h Central include — all library headers, ALLOC/FREE macros - camera.h Viewport state, zoom/pan, MVP matrix, screen↔world transforms - render.h Shape pipeline init/shutdown, per-shape draw calls - shape.h Shape geometry types, procedural generation, hit testing, buffer management - spatial.h Spatial hash grid for accelerated hit tests and rect-select queries - history.h Undo/redo stack with property-level tracking and batch operations - util.h Vector (dynamic array) and memory pool data structures - rand.h Xorshift32 PRNG utilities - shaders/ WGSL shader sources (shape, sprite) + main.c Entry point, sokol init, render loop, input dispatch, UI panels, debug stats + api.h Central include hub — backend defines, all library headers, ALLOC/FREE macros + types.h Shared type definitions, constants, userdata_t + camera.h Viewport state, zoom/pan, MVP matrix (via glm_ortho), screen↔world transforms + render.h Shape & overlay pipeline init/shutdown, shader definitions + shape.h Shape geometry types, procedural generation, Bezier editing, vertex hash grouping, instanced buffer pool + spatial.h Spatial hash grid with linear probing, AABB queries, viewport culling + history.h Undo/redo stack — property-level tracking, vertex snapshots, batch operations + interact.h Selection AABB, group recursive helpers, resize handle hit-test, group rebuild + overlay.h Selection overlay geometry, rotate/corner handles, edit-mode anchor & handle visualization + draw.h Draw dispatch — frustum culling, instance map sorting, instanced draw calls + input.h Mouse/keyboard event handlers — select, move, rotate, resize, pen, edit mode, clipboard + ui_panels.h ImGui panels — toolbar, shape list tree, properties, debug log + util.h Stripe-based vector_t (dynamic array) + rand.h Xorshift32 PRNG + shaders/ WGSL shader sources (sprite, shape, overlay) generated/ xxd-generated C headers from shaders lib/ - sokol/ Sokol single-file headers (gfx, app, glue, log) + sokol/ Sokol single-file headers (gfx, app, glue, log, memtrack) imgui/ Dear ImGui + cimgui cglm/ C linear math library - util/ Sokol utility headers (memtrack, imgui integration) + util/ Sokol utility headers ``` +## Architecture notes + +### Instanced rendering with vertex hash grouping + +Shapes share vertex buffers when their local-space geometry is identical. Each shape stores a 64-bit FNV-1a hash of its vertex data. The geometry pool groups shapes by `(num_elements, vertex_hash)` — not just vertex count. This means: + +- **Parametric shapes** (circles, stars, rectangles from fixed formulas): all instances of the same type naturally produce the same hash, so they share one vertex buffer regardless of count. +- **Freeform paths** (pen tool): each path gets a unique hash, guaranteeing its own vertex buffer and preventing geometry corruption. +- **Bezier edits**: `shape_regenerate_from_ctrl` updates the hash and modifies the group buffer in-place via `sg_update_buffer` rather than destroying/recreating it. + +The previous implementation grouped only by vertex count, which caused pen-drawn paths with matching counts to silently share the wrong geometry. + +### Lazy group index rebuild + +The `g_group_by_id` lookup array is rebuilt lazily. Operations that modify groups set a `g_group_index_dirty` flag; the actual rebuild happens on the first `find_group()` call afterward. This avoids redundant rebuilds when multiple group operations occur within the same frame (e.g., undo then redo, or group then ungroup). + +### Hover state optimization + +`handle_hover` (called every frame) tracks the previous set of highlighted shapes (up to 64) and only toggles state on shapes entering or leaving the highlight set. The O(n) full-array sweep is only used as a fallback when the highlight set exceeds 64 shapes. + +### Pool rebuild granularity + +When the geometry pool rebuilds, existing group vertex buffers whose `(num_elements, vertex_hash)` key still exists are preserved rather than destroyed and recreated. Only new keys trigger buffer creation, and only orphaned keys trigger destruction. The shape data SSBO is also preserved when its size hasn't changed. + +### Spatial grid memory reuse + +The spatial hash grid retains per-slot entry arrays across rebuilds. Only slots whose shape count has grown beyond their current capacity trigger a reallocation. + +### Log deduplication + +The debug log ring buffer uses a 64-bit message hash for fast deduplication of warnings and errors (levels 0-2). Debug-level messages (level 3) skip dedup entirely to avoid the linear scan cost when verbose logging is active. + ## License MIT diff --git a/diff_output.patch b/diff_output.patch deleted file mode 100644 index 285fe07..0000000 --- a/diff_output.patch +++ /dev/null @@ -1,1831 +0,0 @@ -diff --git a/src/api.h b/src/api.h -index dd80adf..a4707c7 100644 ---- a/src/api.h -+++ b/src/api.h -@@ -27,6 +27,21 @@ - - #include "generated/sprite.h" - #include "generated/shape.h" -+#include "generated/overlay.h" -+ -+// Log-to-panel infrastructure -+static void (*g_panel_log_fn)(void*, int, const char*) = NULL; -+static void *g_panel_log_ud = NULL; -+ -+static void panel_log(int level, const char *fmt, ...) { -+ if (!g_panel_log_fn) return; -+ char buf[256]; -+ va_list ap; -+ va_start(ap, fmt); -+ vsnprintf(buf, sizeof(buf), fmt, ap); -+ va_end(ap); -+ g_panel_log_fn(g_panel_log_ud, level, buf); -+} - - #include "util.h" - #include "shape.h" -diff --git a/src/draw.h b/src/draw.h -index 17ad292..e22234b 100644 ---- a/src/draw.h -+++ b/src/draw.h -@@ -9,20 +9,109 @@ static void draw_shapes(userdata_t *ud) - if (g_shape_pool_dirty) - shape_pool_rebuild(&ud->shapes); - -- if (ud->shapes.count == 0) return; -+ int n = ud->shapes.count; -+ if (n == 0) return; -+ -+ if (g_shape_data_dirty) { -+ shape_upload_data(&ud->shapes); -+ g_shape_data_dirty = false; -+ } -+ panel_log(3, "[shapes] draw_shapes: n=%d pipeline=%d", n, shape_pipeline.id); -+ -+ static uint32_t *imap = NULL; -+ static int imap_cap = 0; -+ static uint32_t *ne_counts = NULL; -+ static uint32_t *ne_starts = NULL; -+ static int ne_cap = 0; -+ -+ if (n > imap_cap) { -+ if (imap) FREE(imap); -+ imap = (uint32_t*) ALLOC((size_t)n * sizeof(uint32_t)); -+ imap_cap = n; -+ } -+ -+ // Group shapes by num_elements using counting sort -+ uint32_t max_ne = 0; -+ for (int i = 0; i < n; i++) { -+ uint32_t ne = ((shape_t*) vec_get(&ud->shapes, i))->num_elements; -+ if (ne > max_ne) max_ne = ne; -+ } -+ -+ int ne_size = (int)(max_ne + 1); -+ if (ne_size > ne_cap) { -+ if (ne_counts) FREE(ne_counts); -+ if (ne_starts) FREE(ne_starts); -+ ne_counts = (uint32_t*) ALLOC((size_t)ne_size * sizeof(uint32_t)); -+ ne_starts = (uint32_t*) ALLOC((size_t)ne_size * sizeof(uint32_t)); -+ ne_cap = ne_size; -+ } -+ memset(ne_counts, 0, (size_t)ne_size * sizeof(uint32_t)); -+ -+ for (int i = 0; i < n; i++) { -+ uint32_t ne = ((shape_t*) vec_get(&ud->shapes, i))->num_elements; -+ ne_counts[ne]++; -+ } -+ -+ uint32_t pos = 0; -+ for (uint32_t ne = 0; ne <= max_ne; ne++) { -+ ne_starts[ne] = pos; -+ pos += ne_counts[ne]; -+ ne_counts[ne] = 0; -+ } -+ -+ for (int i = 0; i < n; i++) { -+ uint32_t ne = ((shape_t*) vec_get(&ud->shapes, i))->num_elements; -+ imap[ne_starts[ne] + ne_counts[ne]++] = (uint32_t)i; -+ } -+ -+ shape_upload_instance_map(imap, n); - - sg_apply_pipeline(shape_pipeline); -- sg_apply_bindings(&(sg_bindings){ -- .vertex_buffers[0] = g_shape_vbuf, -- .index_buffer = g_shape_ibuf, -- }); -- for (int i = 0; i < ud->shapes.count; i++) { -- shape_draw((shape_t*) vec_get(&ud->shapes, i), &ud->renderer.uniform.mvp); -+ -+ int base = 0; -+ while (base < n) { -+ shape_t *s = (shape_t*) vec_get(&ud->shapes, imap[base]); -+ uint32_t ne = s->num_elements; -+ int count = 1; -+ while (base + count < n && -+ ((shape_t*) vec_get(&ud->shapes, imap[base + count]))->num_elements == ne) -+ count++; -+ -+ // Find the vertex buffer for this num_elements -+ sg_buffer group_vbuf = {0}; -+ for (int gi = 0; gi < g_shape_group_count; gi++) { -+ if (g_shape_groups[gi].num_elements == ne) { -+ group_vbuf = g_shape_groups[gi].vbuf; -+ break; -+ } -+ } -+ -+ struct { mat4 mvp; uint32_t base; uint8_t _pad[12]; } vs_u; -+ memcpy(vs_u.mvp, ud->renderer.uniform.mvp, sizeof(mat4)); -+ vs_u.base = (uint32_t)base; -+ memset(vs_u._pad, 0, 12); -+ sg_apply_uniforms(0, &SG_RANGE(vs_u)); -+ -+ sg_apply_bindings(&(sg_bindings){ -+ .vertex_buffers[0] = group_vbuf, -+ .views[0] = g_shape_data_view, -+ .views[1] = g_instance_map_view, -+ }); -+ panel_log(3, "[shapes] draw group: ne=%u count=%d base=%d vbuf=%d data_view=%d imap_view=%d", -+ ne, count, base, group_vbuf.id, g_shape_data_view.id, g_instance_map_view.id); -+ sg_draw(0, (int)ne, count); -+ -+ base += count; - } -+ - } - - static void draw_overlay_and_handles(userdata_t *ud, bool has_overlay, bool show_handle) - { -+ sg_apply_pipeline(overlay_pipeline); -+ panel_log(3, "[shapes] draw_overlay: pipeline=%d has_ov=%d show_h=%d", -+ overlay_pipeline.id, has_overlay, show_handle); -+ - if (has_overlay) { - shape_uniform_t u; - glm_mat4_identity(u.transform); -diff --git a/src/generated/shape.h b/src/generated/shape.h -index c5e95d0..a35c0b1 100644 ---- a/src/generated/shape.h -+++ b/src/generated/shape.h -@@ -2,90 +2,109 @@ unsigned char src_shaders_shape_wgsl[] = { - 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x20, 0x56, 0x73, 0x55, 0x6e, 0x69, - 0x66, 0x6f, 0x72, 0x6d, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, - 0x76, 0x70, 0x3a, 0x20, 0x6d, 0x61, 0x74, 0x34, 0x78, 0x34, 0x66, 0x2c, -+ 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, -+ 0x65, 0x5f, 0x62, 0x61, 0x73, 0x65, 0x3a, 0x20, 0x75, 0x33, 0x32, 0x2c, - 0x0a, 0x7d, 0x3b, 0x0a, 0x0a, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x20, -- 0x53, 0x68, 0x61, 0x70, 0x65, 0x55, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, -- 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x74, 0x72, 0x61, 0x6e, 0x73, -- 0x66, 0x6f, 0x72, 0x6d, 0x3a, 0x20, 0x6d, 0x61, 0x74, 0x34, 0x78, 0x34, -- 0x66, 0x2c, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x73, 0x74, 0x61, 0x74, 0x65, -- 0x3a, 0x20, 0x75, 0x33, 0x32, 0x2c, 0x0a, 0x7d, 0x3b, 0x0a, 0x0a, 0x73, -- 0x74, 0x72, 0x75, 0x63, 0x74, 0x20, 0x56, 0x73, 0x49, 0x6e, 0x20, 0x7b, -- 0x0a, 0x20, 0x20, 0x20, 0x20, 0x40, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, -- 0x6f, 0x6e, 0x28, 0x30, 0x29, 0x20, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, -- 0x6f, 0x6e, 0x3a, 0x20, 0x76, 0x65, 0x63, 0x32, 0x66, 0x2c, 0x0a, 0x7d, -+ 0x53, 0x68, 0x61, 0x70, 0x65, 0x44, 0x61, 0x74, 0x61, 0x20, 0x7b, 0x0a, -+ 0x20, 0x20, 0x20, 0x20, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x6f, 0x72, -+ 0x6d, 0x3a, 0x20, 0x6d, 0x61, 0x74, 0x34, 0x78, 0x34, 0x66, 0x2c, 0x0a, -+ 0x20, 0x20, 0x20, 0x20, 0x73, 0x74, 0x61, 0x74, 0x65, 0x3a, 0x20, 0x75, -+ 0x33, 0x32, 0x2c, 0x0a, 0x7d, 0x3b, 0x0a, 0x0a, 0x40, 0x62, 0x69, 0x6e, -+ 0x64, 0x69, 0x6e, 0x67, 0x28, 0x30, 0x29, 0x20, 0x40, 0x67, 0x72, 0x6f, -+ 0x75, 0x70, 0x28, 0x30, 0x29, 0x20, 0x76, 0x61, 0x72, 0x3c, 0x75, 0x6e, -+ 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x3e, 0x20, 0x76, 0x73, 0x5f, 0x75, 0x6e, -+ 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x73, 0x3a, 0x20, 0x56, 0x73, 0x55, 0x6e, -+ 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x3b, 0x0a, 0x0a, 0x40, 0x62, 0x69, 0x6e, -+ 0x64, 0x69, 0x6e, 0x67, 0x28, 0x30, 0x29, 0x20, 0x40, 0x67, 0x72, 0x6f, -+ 0x75, 0x70, 0x28, 0x31, 0x29, 0x20, 0x76, 0x61, 0x72, 0x3c, 0x73, 0x74, -+ 0x6f, 0x72, 0x61, 0x67, 0x65, 0x3e, 0x20, 0x73, 0x68, 0x61, 0x70, 0x65, -+ 0x5f, 0x64, 0x61, 0x74, 0x61, 0x3a, 0x20, 0x61, 0x72, 0x72, 0x61, 0x79, -+ 0x3c, 0x53, 0x68, 0x61, 0x70, 0x65, 0x44, 0x61, 0x74, 0x61, 0x3e, 0x3b, -+ 0x0a, 0x40, 0x62, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x28, 0x31, 0x29, -+ 0x20, 0x40, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x28, 0x31, 0x29, 0x20, 0x76, -+ 0x61, 0x72, 0x3c, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x3e, 0x20, -+ 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x6d, 0x61, 0x70, -+ 0x3a, 0x20, 0x61, 0x72, 0x72, 0x61, 0x79, 0x3c, 0x75, 0x33, 0x32, 0x3e, - 0x3b, 0x0a, 0x0a, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x20, 0x56, 0x73, -- 0x32, 0x46, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x40, 0x62, -- 0x75, 0x69, 0x6c, 0x74, 0x69, 0x6e, 0x28, 0x70, 0x6f, 0x73, 0x69, 0x74, -- 0x69, 0x6f, 0x6e, 0x29, 0x20, 0x70, 0x6f, 0x73, 0x3a, 0x20, 0x76, 0x65, -- 0x63, 0x34, 0x66, 0x2c, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x40, 0x6c, 0x6f, -- 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x30, 0x29, 0x20, 0x40, 0x69, -- 0x6e, 0x74, 0x65, 0x72, 0x70, 0x6f, 0x6c, 0x61, 0x74, 0x65, 0x28, 0x6c, -- 0x69, 0x6e, 0x65, 0x61, 0x72, 0x29, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, -- 0x3a, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x2c, 0x0a, 0x7d, 0x3b, 0x0a, -- 0x0a, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x20, 0x46, 0x73, 0x4f, 0x75, -- 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x40, 0x6c, 0x6f, 0x63, -- 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x30, 0x29, 0x20, 0x63, 0x6f, 0x6c, -- 0x6f, 0x72, 0x3a, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x2c, 0x0a, 0x7d, -- 0x3b, 0x0a, 0x0a, 0x40, 0x62, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x28, -- 0x30, 0x29, 0x20, 0x40, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x28, 0x30, 0x29, -- 0x20, 0x76, 0x61, 0x72, 0x3c, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, -- 0x3e, 0x20, 0x76, 0x73, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, -- 0x73, 0x3a, 0x20, 0x56, 0x73, 0x55, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, -- 0x3b, 0x0a, 0x40, 0x62, 0x69, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x28, 0x31, -- 0x29, 0x20, 0x40, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x28, 0x30, 0x29, 0x20, -- 0x76, 0x61, 0x72, 0x3c, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x3e, -- 0x20, 0x73, 0x68, 0x61, 0x70, 0x65, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, -- 0x72, 0x6d, 0x3a, 0x20, 0x53, 0x68, 0x61, 0x70, 0x65, 0x55, 0x6e, 0x69, -- 0x66, 0x6f, 0x72, 0x6d, 0x3b, 0x0a, 0x0a, 0x40, 0x76, 0x65, 0x72, 0x74, -+ 0x49, 0x6e, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x40, 0x62, 0x75, -+ 0x69, 0x6c, 0x74, 0x69, 0x6e, 0x28, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, -+ 0x63, 0x65, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x29, 0x20, 0x69, 0x6e, -+ 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x78, 0x3a, 0x20, -+ 0x75, 0x33, 0x32, 0x2c, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x40, 0x6c, 0x6f, -+ 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x30, 0x29, 0x20, 0x70, 0x6f, -+ 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x20, 0x76, 0x65, 0x63, 0x32, -+ 0x66, 0x2c, 0x0a, 0x7d, 0x3b, 0x0a, 0x0a, 0x73, 0x74, 0x72, 0x75, 0x63, -+ 0x74, 0x20, 0x56, 0x73, 0x32, 0x46, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, -+ 0x20, 0x20, 0x40, 0x62, 0x75, 0x69, 0x6c, 0x74, 0x69, 0x6e, 0x28, 0x70, -+ 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x29, 0x20, 0x70, 0x6f, 0x73, -+ 0x3a, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x2c, 0x0a, 0x20, 0x20, 0x20, -+ 0x20, 0x40, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x30, -+ 0x29, 0x20, 0x40, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x70, 0x6f, 0x6c, 0x61, -+ 0x74, 0x65, 0x28, 0x6c, 0x69, 0x6e, 0x65, 0x61, 0x72, 0x29, 0x20, 0x63, -+ 0x6f, 0x6c, 0x6f, 0x72, 0x3a, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x2c, -+ 0x0a, 0x7d, 0x3b, 0x0a, 0x0a, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x20, -+ 0x46, 0x73, 0x4f, 0x75, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, -+ 0x40, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x30, 0x29, -+ 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x3a, 0x20, 0x76, 0x65, 0x63, 0x34, -+ 0x66, 0x2c, 0x0a, 0x7d, 0x3b, 0x0a, 0x0a, 0x40, 0x76, 0x65, 0x72, 0x74, - 0x65, 0x78, 0x20, 0x66, 0x6e, 0x20, 0x76, 0x73, 0x5f, 0x6d, 0x61, 0x69, - 0x6e, 0x28, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x3a, 0x20, 0x56, 0x73, 0x49, - 0x6e, 0x29, 0x20, 0x2d, 0x3e, 0x20, 0x56, 0x73, 0x32, 0x46, 0x73, 0x20, - 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x76, 0x61, 0x72, 0x20, 0x6f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x3a, 0x20, 0x56, 0x73, 0x32, 0x46, 0x73, 0x3b, -- 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6c, 0x65, 0x74, 0x20, 0x77, 0x6f, 0x72, -- 0x6c, 0x64, 0x5f, 0x70, 0x6f, 0x73, 0x20, 0x3d, 0x20, 0x73, 0x68, 0x61, -- 0x70, 0x65, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x2e, 0x74, -- 0x72, 0x61, 0x6e, 0x73, 0x66, 0x6f, 0x72, 0x6d, 0x20, 0x2a, 0x20, 0x76, -- 0x65, 0x63, 0x34, 0x66, 0x28, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2e, 0x70, -- 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x78, 0x2c, 0x20, 0x69, -- 0x6e, 0x70, 0x75, 0x74, 0x2e, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, -- 0x6e, 0x2e, 0x79, 0x2c, 0x20, 0x30, 0x2e, 0x30, 0x2c, 0x20, 0x31, 0x2e, -- 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x75, 0x74, 0x70, -- 0x75, 0x74, 0x2e, 0x70, 0x6f, 0x73, 0x20, 0x3d, 0x20, 0x76, 0x73, 0x5f, -- 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x73, 0x2e, 0x6d, 0x76, 0x70, -- 0x20, 0x2a, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x5f, 0x70, 0x6f, 0x73, -- 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x28, 0x73, 0x68, -- 0x61, 0x70, 0x65, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x2e, -- 0x73, 0x74, 0x61, 0x74, 0x65, 0x20, 0x3d, 0x3d, 0x20, 0x32, 0x75, 0x29, -+ 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6c, 0x65, 0x74, 0x20, 0x73, 0x68, 0x61, -+ 0x70, 0x65, 0x5f, 0x69, 0x64, 0x78, 0x20, 0x3d, 0x20, 0x69, 0x6e, 0x73, -+ 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x6d, 0x61, 0x70, 0x5b, 0x76, 0x73, -+ 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x73, 0x2e, 0x69, 0x6e, -+ 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x62, 0x61, 0x73, 0x65, 0x20, -+ 0x2b, 0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2e, 0x69, 0x6e, 0x73, 0x74, -+ 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x78, 0x5d, 0x3b, 0x0a, 0x20, -+ 0x20, 0x20, 0x20, 0x6c, 0x65, 0x74, 0x20, 0x73, 0x68, 0x61, 0x70, 0x65, -+ 0x20, 0x3d, 0x20, 0x73, 0x68, 0x61, 0x70, 0x65, 0x5f, 0x64, 0x61, 0x74, -+ 0x61, 0x5b, 0x73, 0x68, 0x61, 0x70, 0x65, 0x5f, 0x69, 0x64, 0x78, 0x5d, -+ 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6c, 0x65, 0x74, 0x20, 0x77, 0x6f, -+ 0x72, 0x6c, 0x64, 0x5f, 0x70, 0x6f, 0x73, 0x20, 0x3d, 0x20, 0x73, 0x68, -+ 0x61, 0x70, 0x65, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x6f, 0x72, -+ 0x6d, 0x20, 0x2a, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x28, 0x69, 0x6e, -+ 0x70, 0x75, 0x74, 0x2e, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, -+ 0x2e, 0x78, 0x2c, 0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2e, 0x70, 0x6f, -+ 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x79, 0x2c, 0x20, 0x30, 0x2e, -+ 0x30, 0x2c, 0x20, 0x31, 0x2e, 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, -+ 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x2e, 0x70, 0x6f, 0x73, 0x20, -+ 0x3d, 0x20, 0x76, 0x73, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, -+ 0x73, 0x2e, 0x6d, 0x76, 0x70, 0x20, 0x2a, 0x20, 0x77, 0x6f, 0x72, 0x6c, -+ 0x64, 0x5f, 0x70, 0x6f, 0x73, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, -+ 0x66, 0x20, 0x28, 0x73, 0x68, 0x61, 0x70, 0x65, 0x2e, 0x73, 0x74, 0x61, -+ 0x74, 0x65, 0x20, 0x3d, 0x3d, 0x20, 0x32, 0x75, 0x29, 0x20, 0x7b, 0x0a, -+ 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x75, 0x74, 0x70, -+ 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x20, 0x3d, 0x20, 0x76, -+ 0x65, 0x63, 0x34, 0x66, 0x28, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x30, 0x2e, -+ 0x38, 0x34, 0x2c, 0x20, 0x30, 0x2e, 0x30, 0x2c, 0x20, 0x31, 0x2e, 0x30, -+ 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x20, 0x65, 0x6c, 0x73, -+ 0x65, 0x20, 0x69, 0x66, 0x20, 0x28, 0x73, 0x68, 0x61, 0x70, 0x65, 0x2e, -+ 0x73, 0x74, 0x61, 0x74, 0x65, 0x20, 0x3d, 0x3d, 0x20, 0x31, 0x75, 0x29, - 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, - 0x75, 0x74, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x20, -- 0x3d, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x28, 0x31, 0x2e, 0x30, 0x2c, -- 0x20, 0x30, 0x2e, 0x38, 0x34, 0x2c, 0x20, 0x30, 0x2e, 0x30, 0x2c, 0x20, -- 0x31, 0x2e, 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x20, -- 0x65, 0x6c, 0x73, 0x65, 0x20, 0x69, 0x66, 0x20, 0x28, 0x73, 0x68, 0x61, -- 0x70, 0x65, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d, 0x2e, 0x73, -- 0x74, 0x61, 0x74, 0x65, 0x20, 0x3d, 0x3d, 0x20, 0x31, 0x75, 0x29, 0x20, -- 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x75, -- 0x74, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x20, 0x3d, -- 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x28, 0x30, 0x2e, 0x35, 0x2c, 0x20, -- 0x30, 0x2e, 0x36, 0x2c, 0x20, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x31, 0x2e, -- 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x20, 0x65, 0x6c, -- 0x73, 0x65, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, -+ 0x3d, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x28, 0x30, 0x2e, 0x35, 0x2c, -+ 0x20, 0x30, 0x2e, 0x36, 0x2c, 0x20, 0x31, 0x2e, 0x30, 0x2c, 0x20, 0x31, -+ 0x2e, 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x20, 0x65, -+ 0x6c, 0x73, 0x65, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, -+ 0x20, 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, -+ 0x6f, 0x72, 0x20, 0x3d, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x28, 0x30, -+ 0x2e, 0x38, 0x2c, 0x20, 0x30, 0x2e, 0x38, 0x2c, 0x20, 0x30, 0x2e, 0x38, -+ 0x2c, 0x20, 0x31, 0x2e, 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, -+ 0x7d, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, -+ 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x3b, 0x0a, 0x7d, 0x0a, 0x0a, -+ 0x40, 0x66, 0x72, 0x61, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x20, 0x66, 0x6e, -+ 0x20, 0x66, 0x73, 0x5f, 0x6d, 0x61, 0x69, 0x6e, 0x28, 0x69, 0x6e, 0x70, -+ 0x75, 0x74, 0x3a, 0x20, 0x56, 0x73, 0x32, 0x46, 0x73, 0x29, 0x20, 0x2d, -+ 0x3e, 0x20, 0x46, 0x73, 0x4f, 0x75, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, -+ 0x20, 0x20, 0x76, 0x61, 0x72, 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, -+ 0x3a, 0x20, 0x46, 0x73, 0x4f, 0x75, 0x74, 0x3b, 0x0a, 0x20, 0x20, 0x20, - 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, 0x6f, -- 0x72, 0x20, 0x3d, 0x20, 0x76, 0x65, 0x63, 0x34, 0x66, 0x28, 0x30, 0x2e, -- 0x38, 0x2c, 0x20, 0x30, 0x2e, 0x38, 0x2c, 0x20, 0x30, 0x2e, 0x38, 0x2c, -- 0x20, 0x31, 0x2e, 0x30, 0x29, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, -- 0x0a, 0x20, 0x20, 0x20, 0x20, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x20, -- 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x3b, 0x0a, 0x7d, 0x0a, 0x0a, 0x40, -- 0x66, 0x72, 0x61, 0x67, 0x6d, 0x65, 0x6e, 0x74, 0x20, 0x66, 0x6e, 0x20, -- 0x66, 0x73, 0x5f, 0x6d, 0x61, 0x69, 0x6e, 0x28, 0x69, 0x6e, 0x70, 0x75, -- 0x74, 0x3a, 0x20, 0x56, 0x73, 0x32, 0x46, 0x73, 0x29, 0x20, 0x2d, 0x3e, -- 0x20, 0x46, 0x73, 0x4f, 0x75, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, -- 0x20, 0x76, 0x61, 0x72, 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x3a, -- 0x20, 0x46, 0x73, 0x4f, 0x75, 0x74, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, -- 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, 0x6f, 0x72, -- 0x20, 0x3d, 0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, 0x6c, -- 0x6f, 0x72, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x72, 0x65, 0x74, 0x75, -- 0x72, 0x6e, 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x3b, 0x0a, 0x7d, -- 0x0a -+ 0x72, 0x20, 0x3d, 0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2e, 0x63, 0x6f, -+ 0x6c, 0x6f, 0x72, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x72, 0x65, 0x74, -+ 0x75, 0x72, 0x6e, 0x20, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x3b, 0x0a, -+ 0x7d, 0x0a - }; --unsigned int src_shaders_shape_wgsl_len = 1045; -+unsigned int src_shaders_shape_wgsl_len = 1274; -diff --git a/src/history.h b/src/history.h -index cfcf0aa..75471d2 100644 ---- a/src/history.h -+++ b/src/history.h -@@ -3,6 +3,8 @@ - - #include "api.h" - -+#define HISTORY_MAX_DEPTH 256 -+ - typedef enum { - HIST_POSITION, - HIST_SCALE, -@@ -103,6 +105,17 @@ static void history_push_entry(history_t *h, hist_entry_t entry) { - - *((hist_entry_t*) vec_push(&h->entries)) = entry; - h->current = h->entries.count - 1; -+ -+ while (h->entries.count > HISTORY_MAX_DEPTH) { -+ hist_entry_t *e = (hist_entry_t*) vec_get(&h->entries, 0); -+ if (e->changes) { -+ for (int j = 0; j < e->count; j++) -+ hist_free_change(&e->changes[j]); -+ FREE(e->changes); -+ } -+ vec_remove_ordered(&h->entries, 0); -+ h->current--; -+ } - } - - static void history_begin_edit(history_t *h, vector_t *shapes, -@@ -180,14 +193,9 @@ static void history_batch_add(hist_batch_t *batch, int shape_index, hist_prop_t - // Used for HIST_CREATE and HIST_DELETE entries. - // old_val = { kind, cx, cy, num_verts } - // new_val = { sx, sy, rotation, group_id } -+// For procedural shapes (CIRCLE, STAR, RECTANGLE), vertices are reconstructed -+// from parameters instead of deep-copied. For STAR, new_val[1] stores inner_r. - static void hist_snapshot_shape_verts(hist_change_t *c, shape_t *s) { -- int n = (int)s->num_elements; -- c->vertex_count = n; -- c->index_count = n; -- c->vertex_data = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); -- c->index_data = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); -- memcpy(c->vertex_data, s->verts, (size_t)n * sizeof(shape_vertex_t)); -- memcpy(c->index_data, s->indices, (size_t)n * sizeof(uint16_t)); - c->old_val[0] = (float)s->kind; - c->old_val[1] = s->cx; - c->old_val[2] = s->cy; -@@ -196,6 +204,28 @@ static void hist_snapshot_shape_verts(hist_change_t *c, shape_t *s) { - c->new_val[1] = s->sy; - c->new_val[2] = s->rotation; - c->new_val[3] = (float)s->group_id; -+ -+ if (s->kind == SHAPE_GENERIC) { -+ int n = (int)s->num_elements; -+ c->vertex_count = n; -+ c->index_count = n; -+ c->vertex_data = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); -+ c->index_data = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); -+ memcpy(c->vertex_data, s->verts, (size_t)n * sizeof(shape_vertex_t)); -+ memcpy(c->index_data, s->indices, (size_t)n * sizeof(uint16_t)); -+ } else if (s->kind == SHAPE_STAR) { -+ float inner_ratio = sqrtf(s->verts[1].x * s->verts[1].x + s->verts[1].y * s->verts[1].y); -+ c->new_val[1] = inner_ratio * s->sx; -+ c->vertex_count = 0; -+ c->index_count = 0; -+ c->vertex_data = NULL; -+ c->index_data = NULL; -+ } else { -+ c->vertex_count = 0; -+ c->index_count = 0; -+ c->vertex_data = NULL; -+ c->index_data = NULL; -+ } - } - - // Append a CREATE or DELETE entry to a batch, snapshotting the shape's vertex data. -@@ -214,27 +244,46 @@ static void history_batch_commit(hist_batch_t *batch, history_t *h) { - - // Reconstruct a shape_t from a HIST_CREATE / HIST_DELETE change snapshot. - static shape_t hist_rebuild_shape_from_snapshot(const hist_change_t *c) { -+ float cx = c->old_val[1], cy = c->old_val[2]; -+ float sx = c->new_val[0], rot = c->new_val[2]; -+ int gid = (int)c->new_val[3]; - shape_t s; -- memset(&s, 0, sizeof(s)); -- s.kind = (int)c->old_val[0]; -- s.cx = c->old_val[1]; -- s.cy = c->old_val[2]; -- s.num_verts = (uint32_t)c->old_val[3]; -- s.num_elements = (uint32_t)c->vertex_count; -- s.sx = c->new_val[0]; -- s.sy = c->new_val[1]; -- s.rotation = c->new_val[2]; -- s.group_id = (int)c->new_val[3]; -- -- int n = c->vertex_count; -- s.verts = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); -- s.indices = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); -- memcpy(s.verts, c->vertex_data, (size_t)n * sizeof(shape_vertex_t)); -- memcpy(s.indices, c->index_data, (size_t)n * sizeof(uint16_t)); -- -- shape_init_common(&s); -+ -+ switch ((int)c->old_val[0]) { -+ case SHAPE_CIRCLE: -+ s = shape_circle(cx, cy, sx); -+ break; -+ case SHAPE_RECTANGLE: -+ s = shape_rectangle(cx, cy, sx * 2.0f, c->new_val[1] * 2.0f); -+ break; -+ case SHAPE_STAR: { -+ int points = (int)c->old_val[3] / 2; -+ s = shape_star(cx, cy, sx, c->new_val[1], points); -+ break; -+ } -+ default: { -+ memset(&s, 0, sizeof(s)); -+ s.kind = (int)c->old_val[0]; -+ s.cx = cx; -+ s.cy = cy; -+ s.num_verts = (uint32_t)c->old_val[3]; -+ s.num_elements = (uint32_t)c->vertex_count; -+ s.sx = sx; -+ s.sy = c->new_val[1]; -+ int n = c->vertex_count; -+ s.verts = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); -+ s.indices = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); -+ memcpy(s.verts, c->vertex_data, (size_t)n * sizeof(shape_vertex_t)); -+ memcpy(s.indices, c->index_data, (size_t)n * sizeof(uint16_t)); -+ shape_init_common(&s); -+ shape_build_transform(&s); -+ shape_make_buffers(&s); -+ return s; -+ } -+ } -+ s.rotation = rot; -+ s.group_id = gid; - shape_build_transform(&s); -- shape_make_buffers(&s); - return s; - } - -diff --git a/src/input.h b/src/input.h -index ef7c48b..47714bc 100644 ---- a/src/input.h -+++ b/src/input.h -@@ -55,7 +55,7 @@ static void handle_left_down_resize_begin(userdata_t *ud, float wx, float wy, in - int sel_n = 0; - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); -- if (s->selected) { sum_sin += sinf(s->rotation); sum_cos += cosf(s->rotation); sel_n++; } -+ if (s->selected) { sum_sin += s->sin_r; sum_cos += s->cos_r; sel_n++; } - } - ud->interact.resize.angle = atan2f(sum_sin, sum_cos); - -@@ -65,7 +65,7 @@ static void handle_left_down_resize_begin(userdata_t *ud, float wx, float wy, in - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->selected) { -- float sc = cosf(s->rotation), ss = sinf(s->rotation); -+ float sc = s->cos_r, ss = s->sin_r; - float hlx = (ud->interact.resize.start_wx - s->cx) * sc + (ud->interact.resize.start_wy - s->cy) * ss; - float hly = -(ud->interact.resize.start_wx - s->cx) * ss + (ud->interact.resize.start_wy - s->cy) * sc; - float plx = (ud->interact.resize.pivot_x - s->cx) * sc + (ud->interact.resize.pivot_y - s->cy) * ss; -@@ -92,6 +92,12 @@ static void handle_left_down_rotate_begin(userdata_t *ud, float wx, float wy) - wy - ud->interact.rotate.center_y, - wx - ud->interact.rotate.center_x); - ud->interact.rotate.total_delta = 0.0f; -+ -+ ud->interact.drag_indices.count = 0; -+ for (int i = 0; i < ud->shapes.count; i++) { -+ if (((shape_t*) vec_get(&ud->shapes, i))->selected) -+ *(int*) vec_push(&ud->interact.drag_indices) = i; -+ } - } - - static void handle_left_down_move_begin(userdata_t *ud, float wx, float wy) -@@ -101,6 +107,12 @@ static void handle_left_down_move_begin(userdata_t *ud, float wx, float wy) - ud->interact.move.start_wy = wy; - ud->interact.move.total_dx = 0; - ud->interact.move.total_dy = 0; -+ -+ ud->interact.drag_indices.count = 0; -+ for (int i = 0; i < ud->shapes.count; i++) { -+ if (((shape_t*) vec_get(&ud->shapes, i))->selected) -+ *(int*) vec_push(&ud->interact.drag_indices) = i; -+ } - } - - static void handle_left_down_select_or_marquee(userdata_t *ud, const sapp_event *event, float wx, float wy, float tol) -@@ -170,41 +182,37 @@ static void handle_resize_end(userdata_t *ud) - - static void handle_rotate_end(userdata_t *ud) - { -- if (ud->interact.rotate.total_delta != 0.0f) { -- int sel_count = 0; -- for (int i = 0; i < ud->shapes.count; i++) { -- if (((shape_t*) vec_get(&ud->shapes, i))->selected) sel_count++; -- } -- -+ int n = ud->interact.drag_indices.count; -+ if (n > 0 && ud->interact.rotate.total_delta != 0.0f) { - float cos_b = cosf(-ud->interact.rotate.total_delta); - float sin_b = sinf(-ud->interact.rotate.total_delta); - float cx = ud->interact.rotate.center_x; - float cy = ud->interact.rotate.center_y; - - hist_batch_t batch; -- history_batch_init(&batch, sel_count * 2); -+ history_batch_init(&batch, n * 2); - -- for (int i = 0; i < ud->shapes.count; i++) { -+ for (int j = 0; j < n; j++) { -+ int i = *(int*) vec_get(&ud->interact.drag_indices, j); - shape_t *s = (shape_t*) vec_get(&ud->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; -- -- history_batch_add(&batch, i, HIST_POSITION, -- (float[4]){ old_cx, old_cy }, -- (float[4]){ s->cx, s->cy }); -- history_batch_add(&batch, i, HIST_ROTATION, -- (float[4]){ s->rotation - ud->interact.rotate.total_delta }, -- (float[4]){ s->rotation }); -- } -+ 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; -+ -+ history_batch_add(&batch, i, HIST_POSITION, -+ (float[4]){ old_cx, old_cy }, -+ (float[4]){ s->cx, s->cy }); -+ history_batch_add(&batch, i, HIST_ROTATION, -+ (float[4]){ s->rotation - ud->interact.rotate.total_delta }, -+ (float[4]){ s->rotation }); - } - - history_batch_commit(&batch, &ud->history); - } - - ud->interact.rotate.dragging = false; -+ ud->interact.drag_indices.count = 0; - update_shape_states(ud); - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; -@@ -213,28 +221,24 @@ static void handle_rotate_end(userdata_t *ud) - - static void handle_move_end(userdata_t *ud) - { -- if (ud->interact.move.total_dx != 0.0f || ud->interact.move.total_dy != 0.0f) { -- int sel_count = 0; -- for (int i = 0; i < ud->shapes.count; i++) { -- if (((shape_t*) vec_get(&ud->shapes, i))->selected) sel_count++; -- } -- -+ int n = ud->interact.drag_indices.count; -+ if (n > 0 && (ud->interact.move.total_dx != 0.0f || ud->interact.move.total_dy != 0.0f)) { - hist_batch_t batch; -- history_batch_init(&batch, sel_count); -+ history_batch_init(&batch, n); - -- for (int i = 0; i < ud->shapes.count; i++) { -+ for (int j = 0; j < n; j++) { -+ int i = *(int*) vec_get(&ud->interact.drag_indices, j); - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); -- if (s->selected) { -- history_batch_add(&batch, i, HIST_POSITION, -- (float[4]){ s->cx - ud->interact.move.total_dx, s->cy - ud->interact.move.total_dy }, -- (float[4]){ s->cx, s->cy }); -- } -+ history_batch_add(&batch, i, HIST_POSITION, -+ (float[4]){ s->cx - ud->interact.move.total_dx, s->cy - ud->interact.move.total_dy }, -+ (float[4]){ s->cx, s->cy }); - } - - history_batch_commit(&batch, &ud->history); - } - - ud->interact.move.dragging = false; -+ ud->interact.drag_indices.count = 0; - update_shape_states(ud); - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; -@@ -306,7 +310,7 @@ static void handle_resize_drag(userdata_t *ud, const sapp_event *event) - resize_init_t *ini = &ud->interact.resize.init[j]; - shape_t *s = (shape_t*) vec_get(&ud->shapes, ini->idx); - -- float sc = cosf(s->rotation), ss = sinf(s->rotation); -+ float sc = s->cos_r, ss = s->sin_r; - float mlx = (wx - ini->init_cx) * sc + (wy - ini->init_cy) * ss; - float mly = -(wx - ini->init_cx) * ss + (wy - ini->init_cy) * sc; - -@@ -351,17 +355,16 @@ static void handle_rotate_drag(userdata_t *ud, const sapp_event *event) - float cx = ud->interact.rotate.center_x; - float cy = ud->interact.rotate.center_y; - -- for (int i = 0; i < ud->shapes.count; i++) { -+ for (int j = 0; j < ud->interact.drag_indices.count; j++) { -+ int i = *(int*) vec_get(&ud->interact.drag_indices, j); - shape_t *s = (shape_t*) vec_get(&ud->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); -- } -+ 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); - } - - ud->interact.rotate.total_delta = delta; -@@ -376,14 +379,13 @@ static void handle_move_drag(userdata_t *ud, const sapp_event *event) - float delta_x = dx - ud->interact.move.total_dx; - float delta_y = dy - ud->interact.move.total_dy; - -- for (int i = 0; i < ud->shapes.count; i++) { -+ for (int j = 0; j < ud->interact.drag_indices.count; j++) { -+ int i = *(int*) vec_get(&ud->interact.drag_indices, j); - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); -- if (s->selected) { -- s->cx += delta_x; -- s->cy += delta_y; -- shape_build_transform(s); -- shape_set_state(s, false, true); -- } -+ s->cx += delta_x; -+ s->cy += delta_y; -+ shape_retranslate(s); -+ shape_set_state(s, false, true); - } - - ud->interact.move.total_dx = dx; -@@ -411,6 +413,35 @@ static void handle_marquee_drag(userdata_t *ud, const sapp_event *event) - &ud->spatial_grid, &ud->shapes, - min_x, min_y, max_x, max_y); - -+ if (ud->interact.focused_group_id == 0) { -+ int cap = ud->shapes.count; -+ int *gids = (int*) ALLOC((size_t)cap * sizeof(int)); -+ int n_gids = 0; -+ for (int i = 0; i < ud->shapes.count; i++) { -+ shape_t *s = (shape_t*) vec_get(&ud->shapes, i); -+ if (!s->selected || s->group_id == 0) continue; -+ int topmost = get_topmost_group(&ud->groups, s->group_id); -+ bool found = false; -+ for (int j = 0; j < n_gids; j++) { -+ if (gids[j] == topmost) { found = true; break; } -+ } -+ if (!found) gids[n_gids++] = topmost; -+ } -+ for (int j = 0; j < n_gids; j++) { -+ for (int i = 0; i < ud->shapes.count; i++) { -+ shape_t *s = (shape_t*) vec_get(&ud->shapes, i); -+ if (is_shape_in_group_hierarchy(s->group_id, gids[j], &ud->groups)) -+ s->selected = true; -+ } -+ } -+ FREE(gids); -+ ud->interact.selected_count = 0; -+ for (int i = 0; i < ud->shapes.count; i++) { -+ if (((shape_t*) vec_get(&ud->shapes, i))->selected) -+ ud->interact.selected_count++; -+ } -+ } -+ - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - shape_set_state(s, false, s->selected); -@@ -435,16 +466,179 @@ static void handle_hover(userdata_t *ud, const sapp_event *event) - if (hovered >= 0) { - shape_t *hs = (shape_t*) vec_get(&ud->shapes, hovered); - hovered_gid = hs->group_id; -+ if (hovered_gid != 0 && ud->interact.focused_group_id == 0) -+ hovered_gid = get_topmost_group(&ud->groups, hovered_gid); - } - - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - bool in_group = (ud->interact.focused_group_id == 0 && -- hovered_gid != 0 && s->group_id == hovered_gid); -+ hovered_gid != 0 && -+ is_shape_in_group_hierarchy(s->group_id, hovered_gid, &ud->groups)); - shape_set_state(s, (i == hovered || in_group), s->selected); - } - } - -+// -- clipboard -- -+ -+static shape_t clipboard_deep_copy_shape(const shape_t *src) -+{ -+ shape_t dst = *src; -+ dst.verts = (shape_vertex_t*) ALLOC((size_t)src->num_elements * sizeof(shape_vertex_t)); -+ dst.indices = (uint16_t*) ALLOC((size_t)src->num_elements * sizeof(uint16_t)); -+ memcpy(dst.verts, src->verts, (size_t)src->num_elements * sizeof(shape_vertex_t)); -+ memcpy(dst.indices, src->indices, (size_t)src->num_elements * sizeof(uint16_t)); -+ return dst; -+} -+ -+static void clipboard_clear(clipboard_t *cb) -+{ -+ for (int i = 0; i < cb->shape_count; i++) { -+ FREE(cb->shapes[i].verts); -+ FREE(cb->shapes[i].indices); -+ } -+ FREE(cb->shapes); -+ FREE(cb->groups); -+ memset(cb, 0, sizeof(*cb)); -+} -+ -+static int clipboard_lookup_gid(int old_id, const int *map, int map_count) -+{ -+ for (int j = 0; j < map_count; j++) { -+ if (map[j * 2] == old_id) return map[j * 2 + 1]; -+ } -+ return 0; -+} -+ -+static void handle_copy(userdata_t *ud) -+{ -+ if (ud->interact.selected_count == 0) return; -+ -+ clipboard_clear(&ud->clipboard); -+ -+ int n = ud->shapes.count; -+ int sel = 0; -+ for (int i = 0; i < n; i++) { -+ if (((shape_t*) vec_get(&ud->shapes, i))->selected) sel++; -+ } -+ -+ ud->clipboard.shapes = (shape_t*) ALLOC((size_t)sel * sizeof(shape_t)); -+ ud->clipboard.shape_count = 0; -+ -+ int *gids = (int*) ALLOC((size_t)n * sizeof(int)); -+ int n_gids = 0; -+ -+ for (int i = 0; i < n; i++) { -+ shape_t *s = (shape_t*) vec_get(&ud->shapes, i); -+ if (!s->selected) continue; -+ -+ ud->clipboard.shapes[ud->clipboard.shape_count++] = clipboard_deep_copy_shape(s); -+ -+ int gid = s->group_id; -+ while (gid != 0) { -+ bool found = false; -+ for (int j = 0; j < n_gids; j++) { -+ if (gids[j] == gid) { found = true; break; } -+ } -+ if (!found) gids[n_gids++] = gid; -+ group_t *g = find_group(&ud->groups, gid); -+ gid = g ? g->parent_id : 0; -+ } -+ } -+ -+ ud->clipboard.groups = (group_t*) ALLOC((size_t)n_gids * sizeof(group_t)); -+ ud->clipboard.group_count = n_gids; -+ for (int j = 0; j < n_gids; j++) { -+ group_t *src = find_group(&ud->groups, gids[j]); -+ ud->clipboard.groups[j] = *src; -+ } -+ -+ FREE(gids); -+} -+ -+static void handle_paste(userdata_t *ud) -+{ -+ clipboard_t *cb = &ud->clipboard; -+ if (cb->shape_count == 0) return; -+ -+ int gc = cb->group_count; -+ int *gid_map = NULL; -+ if (gc > 0) { -+ gid_map = (int*) ALLOC((size_t)gc * 2 * sizeof(int)); -+ for (int j = 0; j < gc; j++) { -+ gid_map[j * 2] = cb->groups[j].id; -+ gid_map[j * 2 + 1] = ud->next_group_id++; -+ } -+ for (int j = 0; j < gc; j++) { -+ group_t g = cb->groups[j]; -+ g.id = clipboard_lookup_gid(g.id, gid_map, gc); -+ g.parent_id = clipboard_lookup_gid(g.parent_id, gid_map, gc); -+ *((group_t*) vec_push(&ud->groups)) = g; -+ } -+ group_index_rebuild(&ud->groups); -+ } -+ -+ for (int i = 0; i < ud->shapes.count; i++) -+ ((shape_t*) vec_get(&ud->shapes, i))->selected = false; -+ ud->interact.selected_count = 0; -+ -+ float cx, cy; -+ screen_to_world(&ud->camera, ud->mouse_x, ud->mouse_y, &cx, &cy); -+ -+ float cb_min_x = 0, cb_min_y = 0, cb_max_x = 0, cb_max_y = 0; -+ for (int i = 0; i < cb->shape_count; i++) { -+ shape_t *s = &cb->shapes[i]; -+ float sc = s->cos_r, ss = s->sin_r; -+ 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 (i == 0 && v == 0) { -+ cb_min_x = cb_max_x = wx; -+ cb_min_y = cb_max_y = wy; -+ } else { -+ if (wx < cb_min_x) cb_min_x = wx; -+ if (wx > cb_max_x) cb_max_x = wx; -+ if (wy < cb_min_y) cb_min_y = wy; -+ if (wy > cb_max_y) cb_max_y = wy; -+ } -+ } -+ } -+ float cb_cx = (cb_min_x + cb_max_x) * 0.5f; -+ float cb_cy = (cb_min_y + cb_max_y) * 0.5f; -+ -+ int sc = cb->shape_count; -+ hist_batch_t batch; -+ history_batch_init(&batch, sc); -+ -+ for (int i = 0; i < sc; i++) { -+ shape_t s = clipboard_deep_copy_shape(&cb->shapes[i]); -+ s.cx += cx - cb_cx; -+ s.cy += cy - cb_cy; -+ if (gc > 0) -+ s.group_id = clipboard_lookup_gid(s.group_id, gid_map, gc); -+ else -+ s.group_id = 0; -+ s.selected = true; -+ ud->interact.selected_count++; -+ -+ *((shape_t*) vec_push(&ud->shapes)) = s; -+ g_shape_pool_dirty = true; -+ history_batch_add_shape(&batch, ud->shapes.count - 1, HIST_CREATE, -+ (shape_t*) vec_get(&ud->shapes, ud->shapes.count - 1)); -+ } -+ -+ history_batch_commit(&batch, &ud->history); -+ FREE(gid_map); -+ -+ spatial_mark_dirty(&ud->spatial_grid); -+ ud->interact.aabb_cached = false; -+ ud->interact.focused_group_id = 0; -+ ud->overlay_upload_needed = true; -+ update_shape_states(ud); -+} -+ - // -- public event handlers -- - - static bool handle_key_down(userdata_t *ud, const sapp_event *event) -@@ -454,6 +648,7 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) - if (history_undo(&ud->history, &ud->shapes)) { - rebuild_groups_from_shapes(&ud->groups, &ud->shapes); - ud->interact.hovered_shape = -1; -+ g_shape_pool_dirty = true; - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; -@@ -464,12 +659,21 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) - if (history_redo(&ud->history, &ud->shapes)) { - rebuild_groups_from_shapes(&ud->groups, &ud->shapes); - ud->interact.hovered_shape = -1; -+ g_shape_pool_dirty = true; - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; - } - return true; - } -+ if (event->key_code == SAPP_KEYCODE_C) { -+ handle_copy(ud); -+ return true; -+ } -+ if (event->key_code == SAPP_KEYCODE_V) { -+ handle_paste(ud); -+ return true; -+ } - if (event->key_code == SAPP_KEYCODE_G) { - if (event->modifiers & SAPP_MODIFIER_SHIFT) { - // Ungroup: collect unique group IDs of selected shapes -@@ -549,6 +753,8 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) - } - } - -+ group_index_rebuild(&ud->groups); -+ - FREE(parents); - FREE(gids); - ud->ui.list_last_shape = -1; -@@ -624,6 +830,7 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) - - group_t new_grp = { .id = gid, .parent_id = 0 }; - *((group_t*) vec_push(&ud->groups)) = new_grp; -+ group_index_rebuild(&ud->groups); - - for (int j = 0; j < n_full; j++) { - for (int g = 0; g < ud->groups.count; g++) { -@@ -683,6 +890,7 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) - shape_shutdown(s); - vec_remove_ordered(&ud->shapes, indices[j]); - } -+ g_shape_pool_dirty = true; - - FREE(indices); - -@@ -853,6 +1061,9 @@ static void handle_mouse_up(userdata_t *ud, const sapp_event *event) - - static void handle_mouse_move(userdata_t *ud, const sapp_event *event) - { -+ ud->mouse_x = event->mouse_x; -+ ud->mouse_y = event->mouse_y; -+ - if (ud->camera.pan_state.dragging) { - handle_pan_drag(ud, event); - } else if (ud->interact.resize.dragging) { -diff --git a/src/interact.h b/src/interact.h -index b1c97a7..0360207 100644 ---- a/src/interact.h -+++ b/src/interact.h -@@ -11,7 +11,7 @@ static void selected_aabb(userdata_t *ud, float *min_x, float *min_y, - 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); -+ float sc = s->cos_r, ss = s->sin_r; - 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; -@@ -117,6 +117,7 @@ static void rebuild_groups_from_shapes(vector_t *groups, vector_t *shapes) - } - - if (saved) FREE(saved); -+ group_index_rebuild(groups); - } - - static int hit_test_resize_handles(userdata_t *ud, float wx, float wy, float tol) -diff --git a/src/main.c b/src/main.c -index db1bf3c..33aa624 100644 ---- a/src/main.c -+++ b/src/main.c -@@ -34,6 +34,16 @@ static void log_capture(const char* tag, uint32_t log_level, uint32_t log_item, - if (log_level <= 1) ud->ui.log_show = true; - } - -+static void panel_log_impl(void *ud_v, int level, const char *msg) { -+ userdata_t *ud = (userdata_t*)ud_v; -+ int idx = ud->ui.log_head; -+ strncpy(ud->ui.log_ring[idx].text, msg, 255); -+ ud->ui.log_ring[idx].text[255] = 0; -+ ud->ui.log_ring[idx].level = (uint32_t)level; -+ ud->ui.log_head = (idx + 1) % LOG_RING_SIZE; -+ if (ud->ui.log_count < LOG_RING_SIZE) ud->ui.log_count++; -+} -+ - static void meter_fps(userdata_t *ud) - { - float dt = (float)sapp_frame_duration(); -@@ -102,10 +112,14 @@ static void init(void* _userdata) - - userdata_t* ud = (userdata_t*) _userdata; - -+ g_panel_log_fn = panel_log_impl; -+ g_panel_log_ud = ud; -+ - sg_desc sgdesc = { - .environment = sglue_environment(), - .logger.func = log_capture, - .logger.user_data = ud, -+ .uniform_buffer_size = 16 * 1024 * 1024, - }; - sg_setup(&sgdesc); - if (!sg_isvalid()) { -@@ -216,6 +230,7 @@ static void init(void* _userdata) - - vec_init(&ud->shapes, sizeof(shape_t)); - vec_init(&ud->groups, sizeof(group_t)); -+ vec_init(&ud->interact.drag_indices, sizeof(int)); - spatial_init(&ud->spatial_grid); - ud->interact.selected_count = 0; - ud->interact.hovered_shape = -1; -@@ -296,7 +311,7 @@ static void init(void* _userdata) - EM_ASM({ - window.addEventListener('keydown', function(e) { - if (e.ctrlKey && !e.altKey && !e.metaKey) { -- if (e.key === 'z' || e.key === 'y') { -+ if (e.key === 'z' || e.key === 'y' || e.key === 'c' || e.key === 'v') { - e.preventDefault(); - } - } -@@ -316,6 +331,8 @@ static void cleanup(void* _userdata) - spatial_destroy(&ud->spatial_grid); - vec_free(&ud->shapes); - vec_free(&ud->groups); -+ vec_free(&ud->interact.drag_indices); -+ group_index_shutdown(); - history_destroy(&ud->history); - if (ud->interact.resize.init) FREE(ud->interact.resize.init); - sg_destroy_buffer(ud->rect_vbuf); -@@ -329,6 +346,13 @@ static void cleanup(void* _userdata) - shape_pool_shutdown(); - shape_shutdown_pipeline(); - -+ for (int i = 0; i < ud->clipboard.shape_count; i++) { -+ FREE(ud->clipboard.shapes[i].verts); -+ FREE(ud->clipboard.shapes[i].indices); -+ } -+ FREE(ud->clipboard.shapes); -+ FREE(ud->clipboard.groups); -+ - FREE(ud); - - simgui_shutdown(); -diff --git a/src/overlay.h b/src/overlay.h -index e20795b..c22a676 100644 ---- a/src/overlay.h -+++ b/src/overlay.h -@@ -27,7 +27,26 @@ static void compute_overlay_geometry(userdata_t *ud, - overlay_verts[4] = (shape_vertex_t){x1, y1}; - *has_overlay = true; - } else if (ud->interact.selected_count >= 1) { -- if (ud->interact.selected_count == 1) { -+ if (ud->interact.move.dragging && ud->interact.aabb_cached) { -+ float dx = ud->interact.move.total_dx; -+ float dy = ud->interact.move.total_dy; -+ float omin_x = ud->interact.cached_aabb[0] + dx; -+ float omin_y = ud->interact.cached_aabb[1] + dy; -+ float omax_x = ud->interact.cached_aabb[2] + dx; -+ float omax_y = ud->interact.cached_aabb[3] + dy; -+ float pad = 8.0f / ud->camera.zoom; -+ overlay_verts[0] = (shape_vertex_t){omin_x - pad, omin_y - pad}; -+ overlay_verts[1] = (shape_vertex_t){omax_x + pad, omin_y - pad}; -+ overlay_verts[2] = (shape_vertex_t){omax_x + pad, omax_y + pad}; -+ overlay_verts[3] = (shape_vertex_t){omin_x - pad, omax_y + pad}; -+ overlay_verts[4] = overlay_verts[0]; -+ *sel_cx = (omin_x + omax_x) * 0.5f; -+ *sel_cy = (omin_y + omax_y) * 0.5f; -+ *sel_hw = (omax_x - omin_x) * 0.5f + pad; -+ *sel_hh = (omax_y - omin_y) * 0.5f + pad; -+ *sel_angle = 0; -+ *has_overlay = true; -+ } else if (ud->interact.selected_count == 1) { - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (!s->selected) continue; -@@ -52,8 +71,8 @@ static void compute_overlay_geometry(userdata_t *ud, - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (!s->selected) continue; -- sum_sin += sinf(s->rotation); -- sum_cos += cosf(s->rotation); -+ sum_sin += s->sin_r; -+ sum_cos += s->cos_r; - } - selected_aabb(ud, &omin[0], &omin[1], &omax[0], &omax[1]); - ud->interact.cached_aabb[0] = omin[0]; ud->interact.cached_aabb[1] = omin[1]; -@@ -102,10 +121,19 @@ static void upload_overlay_buffers(userdata_t *ud, - ud->interact.rotate.handle_radius = radius; - - const int n = HANDLE_CIRCLE_SEGMENTS + 1; -+ static shape_vertex_t unit_circle[HANDLE_CIRCLE_SEGMENTS + 1]; -+ static bool unit_circle_ready = false; -+ if (!unit_circle_ready) { -+ for (int i = 0; i < n; i++) { -+ float a = (float)i / (float)HANDLE_CIRCLE_SEGMENTS * 2.0f * GLM_PIf; -+ unit_circle[i] = (shape_vertex_t){cosf(a), sinf(a)}; -+ } -+ unit_circle_ready = true; -+ } - 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}; -+ hv[i] = (shape_vertex_t){sel_cx + unit_circle[i].x * radius, -+ sel_cy + unit_circle[i].y * radius}; - } - if (need_upload) - sg_update_buffer(ud->handle_vbuf, &(sg_range){hv, sizeof(hv)}); -diff --git a/src/render.h b/src/render.h -index 7f881b5..b0a4ae0 100644 ---- a/src/render.h -+++ b/src/render.h -@@ -5,6 +5,8 @@ - - static sg_pipeline shape_pipeline; - static sg_shader shape_shader; -+static sg_pipeline overlay_pipeline; -+static sg_shader overlay_shader; - static int g_shape_frame_id; - - static void shape_begin_frame(void) -@@ -14,13 +16,14 @@ static void shape_begin_frame(void) - - static void shape_init_pipeline(void) - { -- shape_shader = sg_make_shader(&(sg_shader_desc) { -+ // Overlay shader/pipeline (simple, no storage buffers) -+ overlay_shader = sg_make_shader(&(sg_shader_desc) { - .vertex_func = { -- .source = (const char*) src_shaders_shape_wgsl, -+ .source = (const char*) src_shaders_overlay_wgsl, - .entry = "vs_main", - }, - .fragment_func = { -- .source = (const char*) src_shaders_shape_wgsl, -+ .source = (const char*) src_shaders_overlay_wgsl, - .entry = "fs_main", - }, - .uniform_blocks = { -@@ -38,31 +41,79 @@ static void shape_init_pipeline(void) - .attrs = { - [0] = { .base_type = SG_SHADERATTRBASETYPE_FLOAT }, - }, -+ .label = "Overlay shader", -+ }); -+ panel_log(3, "[shapes] overlay shader id=%d valid=%d", overlay_shader.id, sg_isvalid()); -+ -+ overlay_pipeline = sg_make_pipeline(&(sg_pipeline_desc) { -+ .shader = overlay_shader, -+ .index_type = SG_INDEXTYPE_UINT16, -+ .primitive_type = SG_PRIMITIVETYPE_LINE_STRIP, -+ .layout.attrs = { -+ [0].format = SG_VERTEXFORMAT_FLOAT2, -+ }, -+ .label = "Overlay pipeline", -+ }); -+ panel_log(3, "[shapes] overlay pipeline id=%d valid=%d", overlay_pipeline.id, sg_isvalid()); -+ -+ // Shape shader/pipeline (storage buffers, instanced) -+ shape_shader = sg_make_shader(&(sg_shader_desc) { -+ .vertex_func = { -+ .source = (const char*) src_shaders_shape_wgsl, -+ .entry = "vs_main", -+ }, -+ .fragment_func = { -+ .source = (const char*) src_shaders_shape_wgsl, -+ .entry = "fs_main", -+ }, -+ .uniform_blocks = { -+ [0] = { -+ .size = 80, -+ .stage = SG_SHADERSTAGE_VERTEX, -+ .wgsl_group0_binding_n = 0, -+ }, -+ }, -+ .views = { -+ [0] = { -+ .storage_buffer = { -+ .stage = SG_SHADERSTAGE_VERTEX, -+ .readonly = true, -+ .wgsl_group1_binding_n = 0, -+ }, -+ }, -+ [1] = { -+ .storage_buffer = { -+ .stage = SG_SHADERSTAGE_VERTEX, -+ .readonly = true, -+ .wgsl_group1_binding_n = 1, -+ }, -+ }, -+ }, -+ .attrs = { -+ [0] = { .base_type = SG_SHADERATTRBASETYPE_FLOAT }, -+ }, - .label = "Shape shader", - }); -+ panel_log(3, "[shapes] shader id=%d valid=%d", shape_shader.id, sg_isvalid()); - - shape_pipeline = sg_make_pipeline(&(sg_pipeline_desc) { - .shader = shape_shader, -- .index_type = SG_INDEXTYPE_UINT16, -+ .index_type = SG_INDEXTYPE_NONE, - .primitive_type = SG_PRIMITIVETYPE_LINE_STRIP, - .layout.attrs = { - [0].format = SG_VERTEXFORMAT_FLOAT2, - }, - .label = "Shape pipeline", - }); -+ panel_log(3, "[shapes] pipeline id=%d valid=%d", shape_pipeline.id, sg_isvalid()); - } - - static void shape_shutdown_pipeline(void) - { - sg_destroy_pipeline(shape_pipeline); - sg_destroy_shader(shape_shader); --} -- --static void shape_draw(shape_t *s, const mat4 *mvp) --{ -- sg_apply_uniforms(0, &SG_RANGE(*mvp)); -- sg_apply_uniforms(1, &SG_RANGE(s->uniform)); -- sg_draw((int)s->index_base, (int)s->num_elements, 1); -+ sg_destroy_pipeline(overlay_pipeline); -+ sg_destroy_shader(overlay_shader); - } - - #endif -diff --git a/src/shaders/shape.wgsl b/src/shaders/shape.wgsl -index e76259a..4d4a3d0 100644 ---- a/src/shaders/shape.wgsl -+++ b/src/shaders/shape.wgsl -@@ -1,13 +1,20 @@ - struct VsUniform { - mvp: mat4x4f, -+ instance_base: u32, - }; - --struct ShapeUniform { -+struct ShapeData { - transform: mat4x4f, - state: u32, - }; - -+@binding(0) @group(0) var vs_uniforms: VsUniform; -+ -+@binding(0) @group(1) var shape_data: array; -+@binding(1) @group(1) var instance_map: array; -+ - struct VsIn { -+ @builtin(instance_index) instance_idx: u32, - @location(0) position: vec2f, - }; - -@@ -20,16 +27,15 @@ struct FsOut { - @location(0) color: vec4f, - }; - --@binding(0) @group(0) var vs_uniforms: VsUniform; --@binding(1) @group(0) var shape_uniform: ShapeUniform; -- - @vertex fn vs_main(input: VsIn) -> Vs2Fs { - var output: Vs2Fs; -- let world_pos = shape_uniform.transform * vec4f(input.position.x, input.position.y, 0.0, 1.0); -+ let shape_idx = instance_map[vs_uniforms.instance_base + input.instance_idx]; -+ let shape = shape_data[shape_idx]; -+ let world_pos = shape.transform * vec4f(input.position.x, input.position.y, 0.0, 1.0); - output.pos = vs_uniforms.mvp * world_pos; -- if (shape_uniform.state == 2u) { -+ if (shape.state == 2u) { - output.color = vec4f(1.0, 0.84, 0.0, 1.0); -- } else if (shape_uniform.state == 1u) { -+ } else if (shape.state == 1u) { - output.color = vec4f(0.5, 0.6, 1.0, 1.0); - } else { - output.color = vec4f(0.8, 0.8, 0.8, 1.0); -diff --git a/src/shape.h b/src/shape.h -index c1b65f3..fa59d5b 100644 ---- a/src/shape.h -+++ b/src/shape.h -@@ -20,6 +20,12 @@ typedef struct shape_uniform_t { - uint8_t _pad[12]; - } shape_uniform_t; - -+typedef struct { -+ mat4 transform; -+ uint32_t state; -+ uint8_t _pad[12]; -+} shape_gpu_data_t; -+ - typedef struct shape_t { - shape_vertex_t *verts; - uint16_t *indices; -@@ -32,11 +38,9 @@ typedef struct shape_t { - float cx, cy; - float sx, sy; - float rotation; -+ float cos_r, sin_r; - int kind; - -- uint32_t vertex_base; -- uint32_t index_base; -- - int group_id; - } shape_t; - -@@ -47,15 +51,55 @@ typedef struct { - int parent_id; // 0 = top-level group - } group_t; - --static group_t* find_group(vector_t *groups, int id) { -+static group_t **g_group_by_id = NULL; -+static int g_group_by_id_cap = 0; -+ -+static void group_index_rebuild(vector_t *groups) -+{ -+ int max_id = 0; -+ for (int i = 0; i < groups->count; i++) { -+ int gid = ((group_t*) vec_get(groups, i))->id; -+ if (gid > max_id) max_id = gid; -+ } -+ if (max_id >= g_group_by_id_cap) { -+ if (g_group_by_id) FREE(g_group_by_id); -+ int new_cap = max_id + 64; -+ g_group_by_id = (group_t**) ALLOC((size_t)new_cap * sizeof(group_t*)); -+ memset(g_group_by_id, 0, (size_t)new_cap * sizeof(group_t*)); -+ g_group_by_id_cap = new_cap; -+ } else { -+ for (int i = 0; i <= max_id; i++) g_group_by_id[i] = NULL; -+ } - for (int i = 0; i < groups->count; i++) { - group_t *g = (group_t*) vec_get(groups, i); -- if (g->id == id) return g; -+ g_group_by_id[g->id] = g; -+ } -+} -+ -+static void group_index_ensure_cap(int max_id) -+{ -+ if (max_id >= g_group_by_id_cap) { -+ int new_cap = max_id + 64; -+ group_t **old = g_group_by_id; -+ g_group_by_id = (group_t**) ALLOC((size_t)new_cap * sizeof(group_t*)); -+ if (old) { -+ memcpy(g_group_by_id, old, (size_t)g_group_by_id_cap * sizeof(group_t*)); -+ FREE(old); -+ } -+ memset(g_group_by_id + g_group_by_id_cap, 0, -+ (size_t)(new_cap - g_group_by_id_cap) * sizeof(group_t*)); -+ g_group_by_id_cap = new_cap; - } -- return NULL; -+} -+ -+static group_t* find_group(vector_t *groups, int id) { -+ (void)groups; -+ if (id <= 0 || id >= g_group_by_id_cap) return NULL; -+ return g_group_by_id[id]; - } - - static int get_topmost_group(vector_t *groups, int gid) { -+ (void)groups; - while (gid != 0) { - group_t *g = find_group(groups, gid); - if (!g || g->parent_id == 0) return gid; -@@ -65,6 +109,7 @@ static int get_topmost_group(vector_t *groups, int gid) { - } - - static bool is_shape_in_group_hierarchy(int shape_gid, int target_gid, vector_t *groups) { -+ (void)groups; - int cur = shape_gid; - while (cur != 0) { - if (cur == target_gid) return true; -@@ -75,74 +120,189 @@ static bool is_shape_in_group_hierarchy(int shape_gid, int target_gid, vector_t - return false; - } - -+static void group_index_shutdown(void) -+{ -+ if (g_group_by_id) FREE(g_group_by_id); -+ g_group_by_id = NULL; -+ g_group_by_id_cap = 0; -+} -+ - // -- shared geometry buffers (one vbuf + one ibuf for all shapes) -- - --static sg_buffer g_shape_vbuf = {0}; --static sg_buffer g_shape_ibuf = {0}; -+static sg_buffer g_shape_data_sbuf = {0}; -+static sg_buffer g_instance_map_sbuf = {0}; -+static sg_view g_shape_data_view = {0}; -+static sg_view g_instance_map_view = {0}; -+ -+// Per-group vertex buffers: one per unique num_elements -+typedef struct { -+ uint32_t num_elements; -+ sg_buffer vbuf; -+} shape_group_buf_t; -+static shape_group_buf_t *g_shape_groups = NULL; -+static int g_shape_group_count = 0; -+ - static bool g_shape_pool_dirty; --static uint32_t g_shape_vert_count; --static uint32_t g_shape_idx_count; -+static bool g_shape_data_dirty; -+static size_t g_shape_data_buf_size = 0; -+static int g_instance_map_capacity = 0; - --static void shape_pool_rebuild(vector_t *shapes) -+static void shape_make_view_for_buffer(sg_view *view, sg_buffer buf) -+{ -+ if (view->id) sg_destroy_view(*view); -+ *view = sg_make_view(&(sg_view_desc){ -+ .storage_buffer = { .buffer = buf }, -+ }); -+} -+ -+static shape_gpu_data_t *g_upload_buf = NULL; -+static int g_upload_buf_cap = 0; -+ -+static void shape_upload_data(vector_t *shapes) - { -- // count total vertices / indices (line strips: num_elements == num_verts + 1) -- uint32_t total_verts = 0, total_indices = 0; -- for (int i = 0; i < shapes->count; i++) { -+ int n = shapes->count; -+ if (n == 0 || !g_shape_data_sbuf.id) return; -+ -+ size_t need = (size_t)n * sizeof(shape_gpu_data_t); -+ if (need > g_shape_data_buf_size) { -+ panel_log(2, "[shapes] upload_data: buffer too small (%zu < %zu), forcing rebuild", -+ g_shape_data_buf_size, need); -+ g_shape_pool_dirty = true; -+ return; -+ } -+ -+ if (n > g_upload_buf_cap) { -+ if (g_upload_buf) FREE(g_upload_buf); -+ g_upload_buf = (shape_gpu_data_t*) ALLOC(need); -+ g_upload_buf_cap = n; -+ } -+ -+ for (int i = 0; i < n; i++) { - shape_t *s = (shape_t*) vec_get(shapes, i); -- total_verts += s->num_elements; -- total_indices += s->num_elements; -+ memcpy(g_upload_buf[i].transform, s->uniform.transform, sizeof(mat4)); -+ g_upload_buf[i].state = s->uniform.state; -+ memset(g_upload_buf[i]._pad, 0, sizeof(g_upload_buf[i]._pad)); -+ } -+ sg_update_buffer(g_shape_data_sbuf, &(sg_range){g_upload_buf, need}); -+} -+ -+static void shape_upload_instance_map(const uint32_t *map, int count) -+{ -+ if (count > g_instance_map_capacity) { -+ if (g_instance_map_sbuf.id) sg_destroy_buffer(g_instance_map_sbuf); -+ g_instance_map_sbuf = sg_make_buffer(&(sg_buffer_desc){ -+ .size = (size_t)count * sizeof(uint32_t), -+ .usage = { .storage_buffer = true, .stream_update = true }, -+ .label = "Instance map", -+ }); -+ g_instance_map_capacity = count; -+ shape_make_view_for_buffer(&g_instance_map_view, g_instance_map_sbuf); -+ } -+ sg_update_buffer(g_instance_map_sbuf, &(sg_range){map, (size_t)count * sizeof(uint32_t)}); -+ panel_log(3, "[shapes] upload_instance_map: count=%d buf=%d view=%d", -+ count, g_instance_map_sbuf.id, g_instance_map_view.id); -+} -+ -+static void shape_pool_rebuild(vector_t *shapes) -+{ -+ int n = shapes->count; -+ -+ // Destroy old groups -+ for (int i = 0; i < g_shape_group_count; i++) { -+ if (g_shape_groups[i].vbuf.id) sg_destroy_buffer(g_shape_groups[i].vbuf); - } -+ FREE(g_shape_groups); -+ g_shape_groups = NULL; -+ g_shape_group_count = 0; - -- if (g_shape_vbuf.id) { sg_destroy_buffer(g_shape_vbuf); g_shape_vbuf.id = 0; } -- if (g_shape_ibuf.id) { sg_destroy_buffer(g_shape_ibuf); g_shape_ibuf.id = 0; } -- g_shape_vert_count = 0; -- g_shape_idx_count = 0; -+ if (g_shape_data_sbuf.id) { sg_destroy_buffer(g_shape_data_sbuf); g_shape_data_sbuf.id = 0; } -+ if (g_shape_data_view.id) { sg_destroy_view(g_shape_data_view); g_shape_data_view.id = 0; } - -- if (total_verts == 0) { -+ if (n == 0) { - g_shape_pool_dirty = false; - return; - } - -- shape_vertex_t *all_v = (shape_vertex_t*) ALLOC((size_t)total_verts * sizeof(shape_vertex_t)); -- uint16_t *all_i = (uint16_t*) ALLOC((size_t)total_indices * sizeof(uint16_t)); -- -- uint32_t voff = 0, ioff = 0; -- for (int i = 0; i < shapes->count; i++) { -- shape_t *s = (shape_t*) vec_get(shapes, i); -- uint32_t n = s->num_elements; -- memcpy(&all_v[voff], s->verts, (size_t)n * sizeof(shape_vertex_t)); -- for (uint32_t j = 0; j < n; j++) -- all_i[ioff + j] = (uint16_t)(voff + s->indices[j]); -- s->vertex_base = voff; -- s->index_base = ioff; -- voff += n; -- ioff += n; -+ g_shape_data_buf_size = (size_t)n * sizeof(shape_gpu_data_t); -+ g_shape_data_sbuf = sg_make_buffer(&(sg_buffer_desc){ -+ .size = g_shape_data_buf_size, -+ .usage = { .storage_buffer = true, .stream_update = true }, -+ .label = "Shape data", -+ }); -+ shape_make_view_for_buffer(&g_shape_data_view, g_shape_data_sbuf); -+ // Data filled by shape_upload_data() in draw_shapes -+ -+ // Count unique num_elements -+ uint32_t max_ne = 0; -+ for (int i = 0; i < n; i++) { -+ uint32_t ne = ((shape_t*) vec_get(shapes, i))->num_elements; -+ if (ne > max_ne) max_ne = ne; - } - -- g_shape_vbuf = sg_make_buffer(&(sg_buffer_desc){ -- .data = { all_v, (size_t)total_verts * sizeof(shape_vertex_t) }, -- .label = "Shape verts (shared)", -- }); -- g_shape_ibuf = sg_make_buffer(&(sg_buffer_desc){ -- .data = { all_i, (size_t)total_indices * sizeof(uint16_t) }, -- .usage = { .index_buffer = true }, -- .label = "Shape indices (shared)", -- }); -+ int *ne_seen = (int*) ALLOC((size_t)(max_ne + 1) * sizeof(int)); -+ memset(ne_seen, 0, (size_t)(max_ne + 1) * sizeof(int)); -+ for (int i = 0; i < n; i++) { -+ uint32_t ne = ((shape_t*) vec_get(shapes, i))->num_elements; -+ ne_seen[ne] = 1; -+ } -+ int group_count = 0; -+ for (uint32_t ne = 0; ne <= max_ne; ne++) -+ if (ne_seen[ne]) group_count++; -+ -+ // Create per-group vertex buffers (one copy of vertex data per unique num_elements) -+ g_shape_groups = (shape_group_buf_t*) ALLOC((size_t)group_count * sizeof(shape_group_buf_t)); -+ memset(g_shape_groups, 0, (size_t)group_count * sizeof(shape_group_buf_t)); -+ -+ int gi = 0; -+ for (uint32_t ne = 0; ne <= max_ne; ne++) { -+ if (!ne_seen[ne]) continue; -+ -+ // Find first shape with this num_elements to use as vertex template -+ shape_t *ref = NULL; -+ for (int i = 0; i < n; i++) { -+ if (((shape_t*) vec_get(shapes, i))->num_elements == ne) { -+ ref = (shape_t*) vec_get(shapes, i); -+ break; -+ } -+ } -+ -+ g_shape_groups[gi].num_elements = ne; -+ g_shape_groups[gi].vbuf = sg_make_buffer(&(sg_buffer_desc){ -+ .data = { ref->verts, (size_t)ne * sizeof(shape_vertex_t) }, -+ .label = "Shape group verts", -+ }); -+ gi++; -+ } -+ g_shape_group_count = group_count; - -- FREE(all_v); -- FREE(all_i); -+ FREE(ne_seen); -+ -+ panel_log(3, "[shapes] pool_rebuild: %d shapes, %d groups, data_buf=%d data_view=%d", -+ n, group_count, g_shape_data_sbuf.id, g_shape_data_view.id); -+ for (int gi = 0; gi < group_count; gi++) { -+ panel_log(3, "[shapes] group[%d]: ne=%u vbuf=%d", -+ gi, g_shape_groups[gi].num_elements, g_shape_groups[gi].vbuf.id); -+ } - -- g_shape_vert_count = total_verts; -- g_shape_idx_count = total_indices; - g_shape_pool_dirty = false; - } - - static void shape_pool_shutdown(void) - { -- if (g_shape_vbuf.id) { sg_destroy_buffer(g_shape_vbuf); g_shape_vbuf.id = 0; } -- if (g_shape_ibuf.id) { sg_destroy_buffer(g_shape_ibuf); g_shape_ibuf.id = 0; } -- g_shape_vert_count = 0; -- g_shape_idx_count = 0; -+ for (int i = 0; i < g_shape_group_count; i++) { -+ if (g_shape_groups[i].vbuf.id) sg_destroy_buffer(g_shape_groups[i].vbuf); -+ } -+ FREE(g_shape_groups); -+ g_shape_groups = NULL; -+ g_shape_group_count = 0; -+ -+ if (g_shape_data_view.id) { sg_destroy_view(g_shape_data_view); g_shape_data_view.id = 0; } -+ if (g_instance_map_view.id) { sg_destroy_view(g_instance_map_view); g_instance_map_view.id = 0; } -+ if (g_shape_data_sbuf.id) { sg_destroy_buffer(g_shape_data_sbuf); g_shape_data_sbuf.id = 0; } -+ if (g_instance_map_sbuf.id) { sg_destroy_buffer(g_instance_map_sbuf); g_instance_map_sbuf.id = 0; } -+ g_instance_map_capacity = 0; -+ if (g_upload_buf) { FREE(g_upload_buf); g_upload_buf = NULL; } -+ g_upload_buf_cap = 0; - } - - #define SHAPE_HOVER_PX 6.0f -@@ -172,11 +332,23 @@ static void shape_build_transform(shape_t *s) - 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); -+ s->cos_r = R[0][0]; -+ s->sin_r = R[0][1]; -+ g_shape_data_dirty = true; -+} -+ -+static void shape_retranslate(shape_t *s) -+{ -+ s->uniform.transform[3][0] = s->cx; -+ s->uniform.transform[3][1] = s->cy; -+ g_shape_data_dirty = true; - } - - static void shape_make_buffers(shape_t *s) - { -- (void)s; -+ for (int i = 0; i < g_shape_group_count; i++) { -+ if (g_shape_groups[i].num_elements == s->num_elements) return; -+ } - g_shape_pool_dirty = true; - } - -@@ -194,9 +366,11 @@ static void shape_regenerate(shape_t *s) - - static void shape_set_state(shape_t *s, bool hovered, bool selected) - { -+ uint32_t new_state = selected ? 2u : (hovered ? 1u : 0u); -+ if (s->uniform.state != new_state) g_shape_data_dirty = true; - s->hovered = hovered; - s->selected = selected; -- s->uniform.state = selected ? 2u : (hovered ? 1u : 0u); -+ s->uniform.state = new_state; - } - - static bool point_in_polygon(float px, float py, shape_vertex_t *verts, uint32_t n) -@@ -213,7 +387,7 @@ static bool point_in_polygon(float px, float py, shape_vertex_t *verts, uint32_t - - static bool shape_hit_test(shape_t *s, float wx, float wy, float world_tol) - { -- float sc = cosf(s->rotation), ss = sinf(s->rotation); -+ float sc = s->cos_r, ss = s->sin_r; - 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; -diff --git a/src/spatial.h b/src/spatial.h -index d3dd24a..51bc4e9 100644 ---- a/src/spatial.h -+++ b/src/spatial.h -@@ -35,8 +35,8 @@ static int spatial_hash(int cx, int cy) - 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 cos_r = s->cos_r; -+ float sin_r = s->sin_r; - 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; -@@ -184,52 +184,34 @@ static int spatial_query_rect_select(spatial_grid_t *grid, vector_t *shapes, - } - 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); -+ for (int s = 0; s < SPATIAL_HASH_SIZE; s++) { -+ if (!grid->slots[s].occupied) continue; -+ for (int e = 0; e < grid->slots[s].count; e++) { -+ spatial_entry_t *entry = &grid->slots[s].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 *shape = (shape_t*) vec_get(shapes, entry->shape_idx); -+ if (shape->selected) continue; -+ -+ bool hit = (shape->cx >= min_x && shape->cx <= max_x && -+ shape->cy >= min_y && shape->cy <= max_y); -+ float sc = shape->cos_r, ss = shape->sin_r; -+ for (uint32_t v = 0; !hit && v < shape->num_verts; v++) { -+ float lx = shape->verts[v].x * shape->sx; -+ float ly = shape->verts[v].y * shape->sy; -+ float wx = shape->cx + lx * sc - ly * ss; -+ float wy = shape->cy + lx * ss + ly * sc; -+ if (wx >= min_x && wx <= max_x && -+ wy >= min_y && wy <= max_y) -+ hit = true; -+ } -+ if (hit) { -+ shape->selected = true; -+ selected_count++; -+ } - } - } - return selected_count; -diff --git a/src/types.h b/src/types.h -index 8a9272f..98fcef8 100644 ---- a/src/types.h -+++ b/src/types.h -@@ -89,6 +89,8 @@ typedef struct { - int focused_group_id; - double last_click_time; - int last_click_shape_idx; -+ -+ vector_t drag_indices; - } interact_state_t; - - typedef struct { -@@ -112,6 +114,13 @@ typedef struct { - int list_prev_count; - } ui_state_t; - -+typedef struct { -+ shape_t *shapes; -+ int shape_count; -+ group_t *groups; -+ int group_count; -+} clipboard_t; -+ - typedef struct userdata_t { - camera_t camera; - renderer_t renderer; -@@ -127,6 +136,8 @@ typedef struct userdata_t { - sg_buffer corner_vbuf, corner_ibuf; - int next_group_id; - vector_t groups; -+ clipboard_t clipboard; -+ float mouse_x, mouse_y; - double time; - } userdata_t; - -diff --git a/src/ui_panels.h b/src/ui_panels.h -index 6d7a243..7ba4920 100644 ---- a/src/ui_panels.h -+++ b/src/ui_panels.h -@@ -352,8 +352,8 @@ static void draw_properties_panel(userdata_t *ud) - (*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, -- s->cx + s->verts[0].x * s->sx * cosf(s->rotation) - s->verts[0].y * s->sy * sinf(s->rotation), -- s->cy + s->verts[0].x * s->sx * sinf(s->rotation) + s->verts[0].y * s->sy * cosf(s->rotation), -+ s->cx + s->verts[0].x * s->sx * s->cos_r - s->verts[0].y * s->sy * s->sin_r, -+ s->cy + s->verts[0].x * s->sx * s->sin_r + s->verts[0].y * s->sy * s->cos_r, - s->cx, s->cy, s->sx, s->sy, s->rotation); - if (igButton("Copy Debug", (ImVec2){0, 0})) - sapp_set_clipboard_string(dbg); -@@ -389,7 +389,9 @@ static void draw_log_panel(userdata_t *ud) - int idx = (start + i) % LOG_RING_SIZE; - off += snprintf(buf + off, (size_t)(cap - off), "%s\n", ud->ui.log_ring[idx].text); - } -- igSetClipboardText(buf); -+ EM_ASM({ -+ navigator.clipboard.writeText(UTF8ToString($0)); -+ }, buf); - FREE(buf); - } - igSameLine(0.0f, 10.0f); diff --git a/makefile b/makefile index 7a2d8f9..4a04ca6 100644 --- a/makefile +++ b/makefile @@ -65,7 +65,8 @@ debug: $(FETCH) $(SHADER_HEADERS) $(C_SOURCES) $(IMGUI_SOURCES) $(CGLM_SOURCES) $(CC) $(C_SOURCES) $(IMGUI_SOURCES) $(CGLM_SOURCES) \ -o $(TARGET) \ $(EMCC_FLAGS) \ - -g -gsource-map=inline \ + -g --profiling-funcs -gsource-map=inline \ + -sASSERTIONS \ -I$(LIB_DIR)/sokol \ -I$(LIB_DIR)/imgui \ -I$(LIB_DIR)/imgui/imgui \ diff --git a/src/api.h b/src/api.h index a4707c7..698117d 100644 --- a/src/api.h +++ b/src/api.h @@ -29,20 +29,33 @@ #include "generated/shape.h" #include "generated/overlay.h" -// Log-to-panel infrastructure -static void (*g_panel_log_fn)(void*, int, const char*) = NULL; -static void *g_panel_log_ud = NULL; +// Log-to-panel infrastructure — ctx pointer passed explicitly. +// The panel_log function writes into the panel's ring buffer through the +// callback registered in panel_log_ctx_t. This avoids a global function +// pointer that would restrict the codebase to a single compilation unit. +typedef struct { + void (*fn)(void*, int, const char*); + void *ud; +} panel_log_ctx_t; -static void panel_log(int level, const char *fmt, ...) { - if (!g_panel_log_fn) return; +static void panel_log(panel_log_ctx_t *pl, int level, const char *fmt, ...) { + if (!pl || !pl->fn) return; char buf[256]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); - g_panel_log_fn(g_panel_log_ud, level, buf); + pl->fn(pl->ud, level, buf); } +// Debug-level log calls are stripped in release builds. All panel_log(3, ...) +// calls should use this macro so they compile to nothing with -O3. +#ifdef NDEBUG +#define panel_log_debug(pl, fmt, ...) ((void)0) +#else +#define panel_log_debug(pl, fmt, ...) panel_log(pl, 3, fmt, ##__VA_ARGS__) +#endif + #include "util.h" #include "shape.h" #include "render.h" @@ -52,8 +65,8 @@ static void panel_log(int level, const char *fmt, ...) { #include "interact.h" #include "overlay.h" #include "draw.h" -#include "ui_panels.h" #include "input.h" +#include "ui_panels.h" #include #include diff --git a/src/camera.h b/src/camera.h index 0df038e..f563240 100644 --- a/src/camera.h +++ b/src/camera.h @@ -17,33 +17,16 @@ typedef struct { pan_state_t pan_state; } camera_t; +// Build a view-projection matrix that maps world coordinates to clip space. +// Uses glm_ortho rather than manual element assignment — the previous +// hand-rolled version was equivalent but obscured the intent. static void compute_mvp(camera_t *cam, mat4 *mvp) { - const float w = (float)cam->width; - const float h = (float)cam->height; - const float z = cam->zoom; - const float px = cam->pan[0]; - const float py = cam->pan[1]; - - (*mvp)[0][0] = (2.0f / w) * z; - (*mvp)[0][1] = 0.0f; - (*mvp)[0][2] = 0.0f; - (*mvp)[0][3] = 0.0f; - - (*mvp)[1][0] = 0.0f; - (*mvp)[1][1] = (2.0f / h) * z; - (*mvp)[1][2] = 0.0f; - (*mvp)[1][3] = 0.0f; - - (*mvp)[2][0] = 0.0f; - (*mvp)[2][1] = 0.0f; - (*mvp)[2][2] = 0.0f; - (*mvp)[2][3] = 0.0f; - - (*mvp)[3][0] = (2.0f / w) * px; - (*mvp)[3][1] = (2.0f / h) * py; - (*mvp)[3][2] = 0.0f; - (*mvp)[3][3] = 1.0f; + float l = (-cam->pan[0] - cam->half_width) / cam->zoom; + float r = (-cam->pan[0] + cam->half_width) / cam->zoom; + float b = (-cam->pan[1] - cam->half_height) / cam->zoom; + float t = (-cam->pan[1] + cam->half_height) / cam->zoom; + glm_ortho(l, r, b, t, -1.0f, 1.0f, *mvp); } static void screen_to_world(camera_t *cam, float mx, float my, float *wx, float *wy) diff --git a/src/draw.h b/src/draw.h index e22234b..1da220f 100644 --- a/src/draw.h +++ b/src/draw.h @@ -6,85 +6,213 @@ static void draw_shapes(userdata_t *ud) { - if (g_shape_pool_dirty) - shape_pool_rebuild(&ud->shapes); + bool pool_was_dirty = ud->shape_pool.pool_dirty; + if (ud->shape_pool.pool_dirty) { + shape_pool_rebuild(&ud->shape_pool, &ud->panel_log_ctx, &ud->shapes); + ud->shape_pool.data_dirty = true; + } int n = ud->shapes.count; if (n == 0) return; - if (g_shape_data_dirty) { - shape_upload_data(&ud->shapes); - g_shape_data_dirty = false; + if (ud->shape_pool.states_dirty) { + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(&ud->shapes, i); + shape_set_state(&ud->shape_pool, s, s->hovered, s->selected); + } + ud->shape_pool.states_dirty = false; } - panel_log(3, "[shapes] draw_shapes: n=%d pipeline=%d", n, shape_pipeline.id); + + if (ud->shape_pool.data_dirty) { + shape_upload_data(&ud->shape_pool, &ud->shapes); + ud->shape_pool.data_dirty = false; + } + + // -- frustum culling: viewport world bounds -- + float vp_min_x = -(ud->camera.pan[0] + ud->camera.half_width) / ud->camera.zoom; + float vp_max_x = (ud->camera.half_width - ud->camera.pan[0]) / ud->camera.zoom; + float vp_min_y = -(ud->camera.half_height + ud->camera.pan[1]) / ud->camera.zoom; + float vp_max_y = (ud->camera.half_height - ud->camera.pan[1]) / ud->camera.zoom; + float margin = FRUSTUM_CULL_MARGIN; + vp_min_x -= margin; vp_max_x += margin; + vp_min_y -= margin; vp_max_y += margin; static uint32_t *imap = NULL; static int imap_cap = 0; - static uint32_t *ne_counts = NULL; - static uint32_t *ne_starts = NULL; - static int ne_cap = 0; + static uint32_t *gi_counts = NULL; + static uint32_t *gi_starts = NULL; + static int gi_cap = 0; + static bool imap_valid = false; + static int *visible = NULL; + static int visible_cap = 0; - if (n > imap_cap) { - if (imap) FREE(imap); - imap = (uint32_t*) ALLOC((size_t)n * sizeof(uint32_t)); - imap_cap = n; - } + int draw_count; + bool any_drag = ud->interact.move.dragging || ud->interact.rotate.dragging || + ud->interact.resize.dragging; + bool use_culling = !any_drag; - // Group shapes by num_elements using counting sort - uint32_t max_ne = 0; - for (int i = 0; i < n; i++) { - uint32_t ne = ((shape_t*) vec_get(&ud->shapes, i))->num_elements; - if (ne > max_ne) max_ne = ne; - } + if (use_culling) { + if (n > visible_cap) { + if (visible) FREE(visible); + visible = (int*) ALLOC((size_t)n * sizeof(int)); + visible_cap = n; + } - int ne_size = (int)(max_ne + 1); - if (ne_size > ne_cap) { - if (ne_counts) FREE(ne_counts); - if (ne_starts) FREE(ne_starts); - ne_counts = (uint32_t*) ALLOC((size_t)ne_size * sizeof(uint32_t)); - ne_starts = (uint32_t*) ALLOC((size_t)ne_size * sizeof(uint32_t)); - ne_cap = ne_size; - } - memset(ne_counts, 0, (size_t)ne_size * sizeof(uint32_t)); + int cell_min_x = (int) floorf(vp_min_x / SPATIAL_CELL_SIZE); + int cell_max_x = (int) floorf(vp_max_x / SPATIAL_CELL_SIZE); + int cell_min_y = (int) floorf(vp_min_y / SPATIAL_CELL_SIZE); + int cell_max_y = (int) floorf(vp_max_y / SPATIAL_CELL_SIZE); + int cell_count = (cell_max_x - cell_min_x + 1) * (cell_max_y - cell_min_y + 1); - for (int i = 0; i < n; i++) { - uint32_t ne = ((shape_t*) vec_get(&ud->shapes, i))->num_elements; - ne_counts[ne]++; - } - - uint32_t pos = 0; - for (uint32_t ne = 0; ne <= max_ne; ne++) { - ne_starts[ne] = pos; - pos += ne_counts[ne]; - ne_counts[ne] = 0; - } - - for (int i = 0; i < n; i++) { - uint32_t ne = ((shape_t*) vec_get(&ud->shapes, i))->num_elements; - imap[ne_starts[ne] + ne_counts[ne]++] = (uint32_t)i; - } - - shape_upload_instance_map(imap, n); - - sg_apply_pipeline(shape_pipeline); - - int base = 0; - while (base < n) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, imap[base]); - uint32_t ne = s->num_elements; - int count = 1; - while (base + count < n && - ((shape_t*) vec_get(&ud->shapes, imap[base + count]))->num_elements == ne) - count++; - - // Find the vertex buffer for this num_elements - sg_buffer group_vbuf = {0}; - for (int gi = 0; gi < g_shape_group_count; gi++) { - if (g_shape_groups[gi].num_elements == ne) { - group_vbuf = g_shape_groups[gi].vbuf; - break; + if (cell_count <= SPATIAL_HASH_SIZE) { + draw_count = spatial_query_viewport(&ud->spatial_grid, + vp_min_x, vp_min_y, vp_max_x, vp_max_y, + visible, n); + } else { + // Viewport too large for cell iteration — linear scan + draw_count = 0; + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(&ud->shapes, i); + if (s->cx + s->aabb_hx < vp_min_x || s->cx - s->aabb_hx > vp_max_x || + s->cy + s->aabb_hy < vp_min_y || s->cy - s->aabb_hy > vp_max_y) + continue; + visible[draw_count++] = i; } } + imap_valid = false; + } else { + draw_count = n; + if (pool_was_dirty) imap_valid = false; + } + + if (draw_count == 0) return; + + // Shrink draw buffers when usage drops far below peak for 60+ frames. + // Prevents WASM memory from being held at a transient high-water mark + // (e.g. after a large paste that was then undone). + { + static int peak_draw_count = 0; + static int peak_gi_cap = 0; + static int peak_visible_cap = 0; + static int frames_at_peak = 0; + + int cur_gi_size = ud->shape_pool.group_count; + + if (draw_count > peak_draw_count || cur_gi_size > peak_gi_cap || n > peak_visible_cap) { + peak_draw_count = draw_count; + peak_gi_cap = cur_gi_size; + peak_visible_cap = use_culling ? n : peak_visible_cap; + frames_at_peak = 0; + } else { + frames_at_peak++; + } + + if (frames_at_peak > 60) { + // Halve buffers when peak is more than 4× current usage + if (imap_cap > 64 && imap_cap > draw_count * 4) { + int new_cap = imap_cap / 2; + if (new_cap < draw_count) new_cap = draw_count; + uint32_t *new_imap = (uint32_t*) ALLOC((size_t)new_cap * sizeof(uint32_t)); + memcpy(new_imap, imap, (size_t)draw_count * sizeof(uint32_t)); + FREE(imap); + imap = new_imap; + imap_cap = new_cap; + imap_valid = false; + peak_draw_count = draw_count; + } + if (gi_cap > 64 && gi_cap > cur_gi_size * 4) { + int new_cap = gi_cap / 2; + if (new_cap < cur_gi_size) new_cap = cur_gi_size; + uint32_t *new_gc = (uint32_t*) ALLOC((size_t)new_cap * sizeof(uint32_t)); + uint32_t *new_gs = (uint32_t*) ALLOC((size_t)new_cap * sizeof(uint32_t)); + if (cur_gi_size > 0) memcpy(new_gc, gi_counts, (size_t)cur_gi_size * sizeof(uint32_t)); + FREE(gi_counts); + FREE(gi_starts); + gi_counts = new_gc; + gi_starts = new_gs; + gi_cap = new_cap; + peak_gi_cap = cur_gi_size; + } + if (visible_cap > 64 && visible_cap > n * 4) { + int new_cap = visible_cap / 2; + if (new_cap < n) new_cap = n; + int *new_vis = (int*) ALLOC((size_t)new_cap * sizeof(int)); + FREE(visible); + visible = new_vis; + visible_cap = new_cap; + peak_visible_cap = n; + } + frames_at_peak = 0; + } + } + + if (draw_count > imap_cap) { + if (imap) FREE(imap); + imap = (uint32_t*) ALLOC((size_t)draw_count * sizeof(uint32_t)); + imap_cap = draw_count; + imap_valid = false; + } + + if (!imap_valid) { + // Sort visible shape indices by group_index so that shapes sharing + // the same vertex buffer are consecutive. This lets the draw loop + // issue one instanced draw call per group run rather than per shape. + int n_groups = ud->shape_pool.group_count; + int max_gi = n_groups - 1; + + int gi_size = max_gi >= 0 ? max_gi + 1 : 0; + if (gi_size > gi_cap) { + if (gi_counts) FREE(gi_counts); + if (gi_starts) FREE(gi_starts); + gi_counts = (uint32_t*) ALLOC((size_t)gi_size * sizeof(uint32_t)); + gi_starts = (uint32_t*) ALLOC((size_t)gi_size * sizeof(uint32_t)); + gi_cap = gi_size; + } + memset(gi_counts, 0, (size_t)gi_size * sizeof(uint32_t)); + + for (int i = 0; i < draw_count; i++) { + int si = use_culling ? visible[i] : i; + int gi = ((shape_t*) vec_get(&ud->shapes, si))->group_index; + if (gi < 0 || gi >= gi_size) gi = 0; + gi_counts[gi]++; + } + + uint32_t pos = 0; + for (int gi = 0; gi < gi_size; gi++) { + gi_starts[gi] = pos; + pos += gi_counts[gi]; + gi_counts[gi] = 0; + } + + for (int i = 0; i < draw_count; i++) { + int si = use_culling ? visible[i] : i; + int gi = ((shape_t*) vec_get(&ud->shapes, si))->group_index; + if (gi < 0 || gi >= gi_size) gi = 0; + imap[gi_starts[gi] + gi_counts[gi]++] = (uint32_t)si; + } + + shape_upload_instance_map(&ud->shape_pool, imap, draw_count); + imap_valid = true; + } + + sg_apply_pipeline(ud->pipelines.shape_pipeline); + + int base = 0; + while (base < draw_count) { + shape_t *s = (shape_t*) vec_get(&ud->shapes, imap[base]); + int gi = s->group_index; + if (gi < 0 || gi >= ud->shape_pool.group_count) gi = 0; + uint32_t ne = s->num_elements; + int count = 1; + while (base + count < draw_count) { + shape_t *ns = (shape_t*) vec_get(&ud->shapes, imap[base + count]); + int ngi = ns->group_index; + if (ngi < 0 || ngi >= ud->shape_pool.group_count) ngi = 0; + if (ngi != gi) break; + count++; + } + + sg_buffer group_vbuf = ud->shape_pool.groups[gi].vbuf; struct { mat4 mvp; uint32_t base; uint8_t _pad[12]; } vs_u; memcpy(vs_u.mvp, ud->renderer.uniform.mvp, sizeof(mat4)); @@ -94,23 +222,20 @@ static void draw_shapes(userdata_t *ud) sg_apply_bindings(&(sg_bindings){ .vertex_buffers[0] = group_vbuf, - .views[0] = g_shape_data_view, - .views[1] = g_instance_map_view, + .views[0] = ud->shape_pool.data_view, + .views[1] = ud->shape_pool.instance_map_view, }); - panel_log(3, "[shapes] draw group: ne=%u count=%d base=%d vbuf=%d data_view=%d imap_view=%d", - ne, count, base, group_vbuf.id, g_shape_data_view.id, g_instance_map_view.id); sg_draw(0, (int)ne, count); base += count; } - } static void draw_overlay_and_handles(userdata_t *ud, bool has_overlay, bool show_handle) { - sg_apply_pipeline(overlay_pipeline); - panel_log(3, "[shapes] draw_overlay: pipeline=%d has_ov=%d show_h=%d", - overlay_pipeline.id, has_overlay, show_handle); + sg_apply_pipeline(ud->pipelines.overlay_pipeline); + panel_log_debug(&ud->panel_log_ctx, "[shapes] draw_overlay: pipeline=%d has_ov=%d show_h=%d", + ud->pipelines.overlay_pipeline.id, has_overlay, show_handle); if (has_overlay) { shape_uniform_t u; @@ -156,6 +281,73 @@ static void draw_overlay_and_handles(userdata_t *ud, bool has_overlay, bool show for (int h = 0; h < 8; h++) sg_draw(h * 5, 5, 1); } } + + // Pen preview + if (ud->pen.drawing && ud->pen.preview_count >= 2) { + sg_update_buffer(ud->pen_vbuf, &(sg_range){ + ud->pen.preview_verts, + (size_t)ud->pen.preview_count * sizeof(shape_vertex_t) + }); + + shape_uniform_t pu; + glm_mat4_identity(pu.transform); + pu.state = 0; + memset(pu._pad, 0, sizeof(pu._pad)); + + sg_apply_uniforms(0, &SG_RANGE(ud->renderer.uniform.mvp)); + sg_apply_uniforms(1, &SG_RANGE(pu)); + sg_apply_bindings(&(sg_bindings){ + .vertex_buffers[0] = ud->pen_vbuf, + .index_buffer = ud->pen_ibuf, + }); + sg_draw(0, ud->pen.preview_count, 1); + } + + // Edit mode overlays + if (ud->interact.editing_shape_idx >= 0) { + shape_uniform_t eu; + glm_mat4_identity(eu.transform); + + // Handle lines (anchor → handle) — drawn as separate 2-vert segments + if (ud->ed_handle_line_count >= 2) { + eu.state = 0; + memset(eu._pad, 0, sizeof(eu._pad)); + sg_apply_uniforms(0, &SG_RANGE(ud->renderer.uniform.mvp)); + sg_apply_uniforms(1, &SG_RANGE(eu)); + sg_apply_bindings(&(sg_bindings){ + .vertex_buffers[0] = ud->ed_handle_line_vbuf, + .index_buffer = ud->ed_shared_ibuf, + }); + int n_lines = ud->ed_handle_line_count / 2; + for (int i = 0; i < n_lines; i++) sg_draw(i * 2, 2, 1); + } + + // Handle squares + if (ud->ed_handle_count > 0) { + eu.state = 0; + memset(eu._pad, 0, sizeof(eu._pad)); + sg_apply_uniforms(0, &SG_RANGE(ud->renderer.uniform.mvp)); + sg_apply_uniforms(1, &SG_RANGE(eu)); + sg_apply_bindings(&(sg_bindings){ + .vertex_buffers[0] = ud->ed_handle_vbuf, + .index_buffer = ud->ed_shared_ibuf, + }); + for (int h = 0; h < ud->ed_handle_count; h++) sg_draw(h * 5, 5, 1); + } + + // Anchor squares (drawn last so they're on top) + if (ud->ed_anchor_count > 0) { + eu.state = 0; + memset(eu._pad, 0, sizeof(eu._pad)); + sg_apply_uniforms(0, &SG_RANGE(ud->renderer.uniform.mvp)); + sg_apply_uniforms(1, &SG_RANGE(eu)); + sg_apply_bindings(&(sg_bindings){ + .vertex_buffers[0] = ud->ed_anchor_vbuf, + .index_buffer = ud->ed_shared_ibuf, + }); + for (int h = 0; h < ud->ed_anchor_count; h++) sg_draw(h * 5, 5, 1); + } + } } #endif diff --git a/src/globals.h b/src/globals.h new file mode 100644 index 0000000..9e655dc --- /dev/null +++ b/src/globals.h @@ -0,0 +1,14 @@ +// Context struct declarations are now defined alongside the code they +// belong to rather than in a single header: +// +// panel_log_ctx_t — api.h +// shape_pool_ctx_t — shape.h (after shape_t / group_t) +// group_index_ctx_t — shape.h +// pipeline_ctx_t — render.h +// shape_group_buf_t — shape.h +// +// This header exists only to avoid breaking includes elsewhere and can +// be removed once all consumers are updated. +#ifndef GLOBALS_H +#define GLOBALS_H +#endif diff --git a/src/history.h b/src/history.h index 75471d2..23caeeb 100644 --- a/src/history.h +++ b/src/history.h @@ -12,6 +12,10 @@ typedef enum { HIST_CREATE, HIST_DELETE, HIST_GROUP, + HIST_GROUP_CREATE, + HIST_GROUP_DELETE, + HIST_GROUP_REPARENT, + HIST_EDIT, } hist_prop_t; typedef struct hist_change_t { @@ -20,12 +24,28 @@ typedef struct hist_change_t { float old_val[4]; float new_val[4]; - // Owned vertex+index buffer snapshot — only used for HIST_CREATE / HIST_DELETE. - // Freed when the history entry is discarded or the stack is destroyed. + // Owned vertex+index buffer snapshot — only used for HIST_CREATE / HIST_DELETE + // when the shape has no control points (pen paths). shape_vertex_t *vertex_data; uint16_t *index_data; int vertex_count; int index_count; + + // Control point snapshot — used for HIST_CREATE / HIST_DELETE / HIST_EDIT. + // For HIST_CREATE / HIST_DELETE, ctrl_* holds the saved shape data. + // For HIST_EDIT, ctrl_* holds the old (pre-edit) state and + // new_ctrl_* holds the new (post-edit) state. + char name[64]; + shape_vertex_t *ctrl_points; + shape_vertex_t *ctrl_handle_in; + shape_vertex_t *ctrl_handle_out; + int ctrl_count; + bool closed; + // Post-edit control point state (only used for HIST_EDIT) + shape_vertex_t *new_ctrl_points; + shape_vertex_t *new_ctrl_handle_in; + shape_vertex_t *new_ctrl_handle_out; + int new_ctrl_count; } hist_change_t; typedef struct hist_entry_t { @@ -67,8 +87,14 @@ static void hist_apply_prop(shape_t *s, hist_prop_t prop, const float val[4]) { } static void hist_free_change(hist_change_t *c) { - if (c->vertex_data) FREE(c->vertex_data); - if (c->index_data) FREE(c->index_data); + if (c->vertex_data) FREE(c->vertex_data); + if (c->index_data) FREE(c->index_data); + if (c->ctrl_points) FREE(c->ctrl_points); + if (c->ctrl_handle_in) FREE(c->ctrl_handle_in); + if (c->ctrl_handle_out) FREE(c->ctrl_handle_out); + if (c->new_ctrl_points) FREE(c->new_ctrl_points); + if (c->new_ctrl_handle_in) FREE(c->new_ctrl_handle_in); + if (c->new_ctrl_handle_out) FREE(c->new_ctrl_handle_out); memset(c, 0, sizeof(*c)); } @@ -189,23 +215,34 @@ static void history_batch_add(hist_batch_t *batch, int shape_index, hist_prop_t memcpy(c->new_val, new_val, sizeof(float[4])); } -// Snapshot a shape's full vertex data and metadata into a change entry. -// Used for HIST_CREATE and HIST_DELETE entries. -// old_val = { kind, cx, cy, num_verts } -// new_val = { sx, sy, rotation, group_id } -// For procedural shapes (CIRCLE, STAR, RECTANGLE), vertices are reconstructed -// from parameters instead of deep-copied. For STAR, new_val[1] stores inner_r. +// Snapshot a shape's full data into a change entry. +// old_val = { cx, cy, num_elements, 0 } +// new_val = { sx, sy, rotation, group_id } +// For procedural shapes (ctrl_count > 0), control points are deep-copied. +// For pen paths (ctrl_count == 0), raw vertex/index data is deep-copied. static void hist_snapshot_shape_verts(hist_change_t *c, shape_t *s) { - c->old_val[0] = (float)s->kind; - c->old_val[1] = s->cx; - c->old_val[2] = s->cy; - c->old_val[3] = (float)s->num_verts; + c->old_val[0] = s->cx; + c->old_val[1] = s->cy; + c->old_val[2] = (float)(int)s->num_elements; + c->old_val[3] = 0; c->new_val[0] = s->sx; c->new_val[1] = s->sy; c->new_val[2] = s->rotation; c->new_val[3] = (float)s->group_id; - if (s->kind == SHAPE_GENERIC) { + strncpy(c->name, s->name, sizeof(c->name) - 1); + c->name[sizeof(c->name) - 1] = '\0'; + c->closed = s->closed; + + if (s->ctrl_count > 0) { + c->ctrl_count = s->ctrl_count; + c->ctrl_points = (shape_vertex_t*) ALLOC((size_t)c->ctrl_count * sizeof(shape_vertex_t)); + c->ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)c->ctrl_count * sizeof(shape_vertex_t)); + c->ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)c->ctrl_count * sizeof(shape_vertex_t)); + memcpy(c->ctrl_points, s->ctrl_points, (size_t)c->ctrl_count * sizeof(shape_vertex_t)); + memcpy(c->ctrl_handle_in, s->ctrl_handle_in, (size_t)c->ctrl_count * sizeof(shape_vertex_t)); + memcpy(c->ctrl_handle_out, s->ctrl_handle_out, (size_t)c->ctrl_count * sizeof(shape_vertex_t)); + } else { int n = (int)s->num_elements; c->vertex_count = n; c->index_count = n; @@ -213,22 +250,10 @@ static void hist_snapshot_shape_verts(hist_change_t *c, shape_t *s) { c->index_data = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); memcpy(c->vertex_data, s->verts, (size_t)n * sizeof(shape_vertex_t)); memcpy(c->index_data, s->indices, (size_t)n * sizeof(uint16_t)); - } else if (s->kind == SHAPE_STAR) { - float inner_ratio = sqrtf(s->verts[1].x * s->verts[1].x + s->verts[1].y * s->verts[1].y); - c->new_val[1] = inner_ratio * s->sx; - c->vertex_count = 0; - c->index_count = 0; - c->vertex_data = NULL; - c->index_data = NULL; - } else { - c->vertex_count = 0; - c->index_count = 0; - c->vertex_data = NULL; - c->index_data = NULL; } } -// Append a CREATE or DELETE entry to a batch, snapshotting the shape's vertex data. +// Append a CREATE or DELETE entry to a batch, snapshotting the shape's data. static void history_batch_add_shape(hist_batch_t *batch, int shape_index, hist_prop_t prop, shape_t *s) { hist_change_t *c = &batch->changes[batch->count++]; @@ -237,61 +262,112 @@ static void history_batch_add_shape(hist_batch_t *batch, int shape_index, hist_snapshot_shape_verts(c, s); } +// Snapshot both old and new control point states for a HIST_EDIT change. +static void history_batch_add_edit(hist_batch_t *batch, int shape_index, shape_t *s, + const shape_vertex_t *old_pts, + const shape_vertex_t *old_hin, + const shape_vertex_t *old_hout, + int old_count) { + hist_change_t *c = &batch->changes[batch->count++]; + c->shape_index = shape_index; + c->prop = HIST_EDIT; + // Store shape metadata + c->old_val[0] = s->cx; + c->old_val[1] = s->cy; + c->old_val[2] = (float)(int)s->num_elements; + c->old_val[3] = 0; + c->new_val[0] = s->sx; + c->new_val[1] = s->sy; + c->new_val[2] = s->rotation; + c->new_val[3] = (float)s->group_id; + strncpy(c->name, s->name, sizeof(c->name) - 1); + c->name[sizeof(c->name) - 1] = '\0'; + c->closed = s->closed; + // Snapshot old ctrl state + c->ctrl_count = old_count; + c->ctrl_points = (shape_vertex_t*) ALLOC((size_t)old_count * sizeof(shape_vertex_t)); + c->ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)old_count * sizeof(shape_vertex_t)); + c->ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)old_count * sizeof(shape_vertex_t)); + memcpy(c->ctrl_points, old_pts, (size_t)old_count * sizeof(shape_vertex_t)); + memcpy(c->ctrl_handle_in, old_hin, (size_t)old_count * sizeof(shape_vertex_t)); + memcpy(c->ctrl_handle_out, old_hout, (size_t)old_count * sizeof(shape_vertex_t)); + // Snapshot new ctrl state + c->new_ctrl_count = s->ctrl_count; + c->new_ctrl_points = (shape_vertex_t*) ALLOC((size_t)s->ctrl_count * sizeof(shape_vertex_t)); + c->new_ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)s->ctrl_count * sizeof(shape_vertex_t)); + c->new_ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)s->ctrl_count * sizeof(shape_vertex_t)); + memcpy(c->new_ctrl_points, s->ctrl_points, (size_t)s->ctrl_count * sizeof(shape_vertex_t)); + memcpy(c->new_ctrl_handle_in, s->ctrl_handle_in, (size_t)s->ctrl_count * sizeof(shape_vertex_t)); + memcpy(c->new_ctrl_handle_out, s->ctrl_handle_out, (size_t)s->ctrl_count * sizeof(shape_vertex_t)); +} + static void history_batch_commit(hist_batch_t *batch, history_t *h) { hist_entry_t entry = { .changes = batch->changes, .count = batch->count }; history_push_entry(h, entry); } // Reconstruct a shape_t from a HIST_CREATE / HIST_DELETE change snapshot. -static shape_t hist_rebuild_shape_from_snapshot(const hist_change_t *c) { - float cx = c->old_val[1], cy = c->old_val[2]; - float sx = c->new_val[0], rot = c->new_val[2]; +static shape_t hist_rebuild_shape_from_snapshot(shape_pool_ctx_t *sp, const hist_change_t *c) { + float cx = c->old_val[0], cy = c->old_val[1]; + float sx = c->new_val[0], sy = c->new_val[1]; + float rot = c->new_val[2]; int gid = (int)c->new_val[3]; shape_t s; - switch ((int)c->old_val[0]) { - case SHAPE_CIRCLE: - s = shape_circle(cx, cy, sx); - break; - case SHAPE_RECTANGLE: - s = shape_rectangle(cx, cy, sx * 2.0f, c->new_val[1] * 2.0f); - break; - case SHAPE_STAR: { - int points = (int)c->old_val[3] / 2; - s = shape_star(cx, cy, sx, c->new_val[1], points); - break; - } - default: { - memset(&s, 0, sizeof(s)); - s.kind = (int)c->old_val[0]; - s.cx = cx; - s.cy = cy; - s.num_verts = (uint32_t)c->old_val[3]; - s.num_elements = (uint32_t)c->vertex_count; - s.sx = sx; - s.sy = c->new_val[1]; - int n = c->vertex_count; - s.verts = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); - s.indices = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); - memcpy(s.verts, c->vertex_data, (size_t)n * sizeof(shape_vertex_t)); - memcpy(s.indices, c->index_data, (size_t)n * sizeof(uint16_t)); - shape_init_common(&s); - shape_build_transform(&s); - shape_make_buffers(&s); - return s; - } + if (c->ctrl_count > 0) { + memset(&s, 0, sizeof(s)); + s.cx = cx; + s.cy = cy; + s.sx = sx; + s.sy = sy; + s.rotation = rot; + shape_init_common(&s); + strncpy(s.name, c->name, sizeof(s.name) - 1); + s.closed = c->closed; + s.ctrl_count = c->ctrl_count; + s.ctrl_points = (shape_vertex_t*) ALLOC((size_t)c->ctrl_count * sizeof(shape_vertex_t)); + s.ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)c->ctrl_count * sizeof(shape_vertex_t)); + s.ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)c->ctrl_count * sizeof(shape_vertex_t)); + memcpy(s.ctrl_points, c->ctrl_points, (size_t)c->ctrl_count * sizeof(shape_vertex_t)); + memcpy(s.ctrl_handle_in, c->ctrl_handle_in, (size_t)c->ctrl_count * sizeof(shape_vertex_t)); + memcpy(s.ctrl_handle_out, c->ctrl_handle_out, (size_t)c->ctrl_count * sizeof(shape_vertex_t)); + shape_regenerate_from_ctrl(sp, &s); + } else { + memset(&s, 0, sizeof(s)); + s.cx = cx; + s.cy = cy; + s.rotation = rot; + s.num_verts = (uint32_t)c->vertex_count; + s.num_elements = (uint32_t)c->vertex_count; + s.sx = sx; + s.sy = sy; + int n = c->vertex_count; + s.verts = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + s.indices = (uint16_t*) ALLOC((size_t)n * sizeof(uint16_t)); + memcpy(s.verts, c->vertex_data, (size_t)n * sizeof(shape_vertex_t)); + memcpy(s.indices, c->index_data, (size_t)n * sizeof(uint16_t)); + shape_init_common(&s); + strncpy(s.name, c->name, sizeof(s.name) - 1); + s.vertex_hash = hash_vertex_data(s.verts, s.num_elements); + shape_build_transform(sp, &s); + shape_update_aabb(&s); + shape_make_buffers(sp, &s); } s.rotation = rot; s.group_id = gid; - shape_build_transform(&s); return s; } -static void history_apply_entry(hist_entry_t *entry, vector_t *shapes, bool forward) { +static void history_apply_entry(history_t *h, vector_t *shapes, + shape_pool_ctx_t *sp, vector_t *groups, + group_index_ctx_t *gi, bool forward) { + (void)h; + hist_entry_t *entry = (hist_entry_t*) vec_get(&h->entries, h->current); bool has_shape_ops = false; for (int i = 0; i < entry->count; i++) { hist_prop_t p = entry->changes[i].prop; - if (p == HIST_CREATE || p == HIST_DELETE) { has_shape_ops = true; break; } + if (p == HIST_CREATE || p == HIST_DELETE || + p == HIST_GROUP_CREATE || p == HIST_GROUP_DELETE) { has_shape_ops = true; break; } } int start = 0, end = entry->count, step = 1; @@ -304,40 +380,119 @@ static void history_apply_entry(hist_entry_t *entry, vector_t *shapes, bool forw for (int i = start; i != end; i += step) { hist_change_t *c = &entry->changes[i]; + if (c->prop == HIST_GROUP_CREATE) { + int gid = (int)c->new_val[0]; + int parent_id = (int)c->new_val[1]; + if (forward) { + group_t g = { .id = gid, .parent_id = parent_id, .collapsed = false }; + *((group_t*) vec_push(groups)) = g; + } else { + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->id == gid) { + if (grp->member_indices) FREE(grp->member_indices); + vec_remove_ordered(groups, g); + break; + } + } + } + gi->dirty = true; + continue; + } + + if (c->prop == HIST_GROUP_DELETE) { + int gid = (int)c->old_val[0]; + int parent_id = (int)c->old_val[1]; + if (forward) { + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->id == gid) { + if (grp->member_indices) FREE(grp->member_indices); + vec_remove_ordered(groups, g); + break; + } + } + } else { + group_t g = { .id = gid, .parent_id = parent_id, .collapsed = false }; + *((group_t*) vec_push(groups)) = g; + } + gi->dirty = true; + continue; + } + + if (c->prop == HIST_GROUP_REPARENT) { + int gid = (int)c->new_val[0]; + int new_pid = forward ? (int)c->new_val[1] : (int)c->old_val[1]; + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->id == gid) { + grp->parent_id = new_pid; + break; + } + } + continue; + } + if (c->prop == HIST_CREATE || c->prop == HIST_DELETE) { bool adding = (c->prop == HIST_CREATE) ? forward : !forward; if (adding) { - shape_t s = hist_rebuild_shape_from_snapshot(c); + if (c->shape_index < 0 || c->shape_index > shapes->count) continue; + shape_t s = hist_rebuild_shape_from_snapshot(sp, c); *((shape_t*) vec_insert(shapes, c->shape_index)) = s; } else { if (c->shape_index < shapes->count) { shape_t *s = (shape_t*) vec_get(shapes, c->shape_index); - shape_shutdown(s); + shape_shutdown(sp, s); vec_remove_ordered(shapes, c->shape_index); } } continue; } + if (c->prop == HIST_EDIT) { + if (c->shape_index >= shapes->count) continue; + shape_t *s = (shape_t*) vec_get(shapes, c->shape_index); + shape_vertex_t *pts = forward ? c->new_ctrl_points : c->ctrl_points; + shape_vertex_t *hin = forward ? c->new_ctrl_handle_in : c->ctrl_handle_in; + shape_vertex_t *hout = forward ? c->new_ctrl_handle_out : c->ctrl_handle_out; + int cc = forward ? c->new_ctrl_count : c->ctrl_count; + if (!pts || cc <= 0) continue; + FREE(s->ctrl_points); + FREE(s->ctrl_handle_in); + FREE(s->ctrl_handle_out); + s->ctrl_count = cc; + s->ctrl_points = (shape_vertex_t*) ALLOC((size_t)cc * sizeof(shape_vertex_t)); + s->ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)cc * sizeof(shape_vertex_t)); + s->ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)cc * sizeof(shape_vertex_t)); + memcpy(s->ctrl_points, pts, (size_t)cc * sizeof(shape_vertex_t)); + memcpy(s->ctrl_handle_in, hin, (size_t)cc * sizeof(shape_vertex_t)); + memcpy(s->ctrl_handle_out, hout, (size_t)cc * sizeof(shape_vertex_t)); + shape_regenerate_from_ctrl(sp, s); + shape_set_state(sp, s, s->hovered, s->selected); + continue; + } + 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); + shape_regenerate(sp, s); + shape_set_state(sp, s, s->hovered, s->selected); } } -static bool history_undo(history_t *h, vector_t *shapes) { - if (h->current < 0) return false; - history_apply_entry((hist_entry_t*) vec_get(&h->entries, h->current), shapes, false); +static bool history_undo(history_t *h, vector_t *shapes, shape_pool_ctx_t *sp, + vector_t *groups, group_index_ctx_t *gi) { + if (h->current < 0 || h->current >= h->entries.count) return false; + history_apply_entry(h, shapes, sp, groups, gi, false); h->current--; return true; } -static bool history_redo(history_t *h, vector_t *shapes) { +static bool history_redo(history_t *h, vector_t *shapes, shape_pool_ctx_t *sp, + vector_t *groups, group_index_ctx_t *gi) { if (h->current + 1 >= h->entries.count) return false; h->current++; - history_apply_entry((hist_entry_t*) vec_get(&h->entries, h->current), shapes, true); + history_apply_entry(h, shapes, sp, groups, gi, true); return true; } diff --git a/src/input.h b/src/input.h index 47714bc..8a88846 100644 --- a/src/input.h +++ b/src/input.h @@ -6,25 +6,184 @@ #include "interact.h" #include "overlay.h" +// -- edit mode helpers -- + +static int hit_test_edit_point(userdata_t *ud, float wx, float wy, + int *anchor_idx, bool *is_handle, bool *is_in) { + *anchor_idx = -1; + *is_handle = false; + *is_in = false; + int idx = ud->interact.editing_shape_idx; + if (idx < 0 || idx >= ud->shapes.count) return -1; + shape_t *s = (shape_t*) vec_get(&ud->shapes, idx); + if (s->ctrl_count <= 0) return -1; + + float as = EDIT_ANCHOR_SIZE_PX / ud->camera.zoom; + float hs = EDIT_HANDLE_SIZE_PX / ud->camera.zoom; + + // Hit test anchors + for (int i = 0; i < s->ctrl_count; i++) { + shape_vertex_t wp = local_to_world(s, s->ctrl_points[i].x, s->ctrl_points[i].y); + if (fabsf(wx - wp.x) <= as && fabsf(wy - wp.y) <= as) { + *anchor_idx = i; + return 0; // hit anchor + } + } + + // Hit test handles + for (int i = 0; i < s->ctrl_count; i++) { + shape_vertex_t wh_in = local_to_world(s, s->ctrl_handle_in[i].x, s->ctrl_handle_in[i].y); + shape_vertex_t wh_out = local_to_world(s, s->ctrl_handle_out[i].x, s->ctrl_handle_out[i].y); + if (fabsf(wx - wh_in.x) <= hs && fabsf(wy - wh_in.y) <= hs) { + *anchor_idx = i; + *is_handle = true; + *is_in = true; + return 1; // hit in-handle + } + if (fabsf(wx - wh_out.x) <= hs && fabsf(wy - wh_out.y) <= hs) { + *anchor_idx = i; + *is_handle = true; + *is_in = false; + return 1; // hit out-handle + } + } + + return -1; // no hit +} + +// -- pen tool helpers -- + +// Project world-space mouse position to local shape space (used when dragging +// edit anchors/handles within the edit mode shape). +static void world_to_local_shape(shape_t *s, float wx, float wy, float *lx, float *ly) { + float dx = wx - s->cx, dy = wy - s->cy; + *lx = (dx * s->cos_r + dy * s->sin_r) / s->sx; + *ly = (-dx * s->sin_r + dy * s->cos_r) / s->sy; +} + +// Generate a Catmull-Rom preview curve through the pen tool anchor points. +static void pen_update_preview(userdata_t *ud) { + int n = ud->pen.point_count; + if (n < 2) { + ud->pen.preview_count = 0; + return; + } + + shape_vertex_t *pts = ud->pen.points; + int subdivisions = 8; + int max_out = (n - 1) * subdivisions + 1; + if (max_out > PEN_PREVIEW_MAX_VERTS) { + subdivisions = (PEN_PREVIEW_MAX_VERTS - 1) / (n - 1); + if (subdivisions < 1) subdivisions = 1; + max_out = (n - 1) * subdivisions + 1; + } + + for (int seg = 0; seg < n - 1; seg++) { + shape_vertex_t p0 = pts[seg > 0 ? seg - 1 : 0]; + shape_vertex_t p1 = pts[seg]; + shape_vertex_t p2 = pts[seg + 1]; + shape_vertex_t p3 = pts[seg + 2 < n ? seg + 2 : n - 1]; + + int steps = subdivisions; + if (seg == n - 2) steps = max_out - seg * subdivisions; + for (int s = 0; s < steps; s++) { + float t = (float)s / (float)steps; + float t2 = t * t, t3 = t2 * t; + ud->pen.preview_verts[seg * subdivisions + s] = (shape_vertex_t){ + 0.5f * ((2.0f * p1.x) + + (-p0.x + p2.x) * t + + (2.0f * p0.x - 5.0f * p1.x + 4.0f * p2.x - p3.x) * t2 + + (-p0.x + 3.0f * p1.x - 3.0f * p2.x + p3.x) * t3), + 0.5f * ((2.0f * p1.y) + + (-p0.y + p2.y) * t + + (2.0f * p0.y - 5.0f * p1.y + 4.0f * p2.y - p3.y) * t2 + + (-p0.y + 3.0f * p1.y - 3.0f * p2.y + p3.y) * t3) + }; + } + } + ud->pen.preview_verts[max_out - 1] = pts[n - 1]; + ud->pen.preview_count = max_out; +} + +// Finish the pen tool: create a shape from collected world-space anchor points, +// setting up Bezier control points so the shape can be edited later. +static void pen_finish_shape(userdata_t *ud) { + int n = ud->pen.point_count; + if (n < 2) { ud->pen.drawing = false; ud->pen.point_count = 0; return; } + + shape_vertex_t *wpts = ud->pen.points; + + // Compute world-space AABB + float min_x = wpts[0].x, min_y = wpts[0].y; + float max_x = min_x, max_y = min_y; + for (int i = 1; i < n; i++) { + if (wpts[i].x < min_x) min_x = wpts[i].x; + if (wpts[i].y < min_y) min_y = wpts[i].y; + if (wpts[i].x > max_x) max_x = wpts[i].x; + if (wpts[i].y > max_y) max_y = wpts[i].y; + } + float hx = (max_x - min_x) * 0.5f; + float hy = (max_y - min_y) * 0.5f; + if (hx < 0.0001f) hx = 1.0f; + if (hy < 0.0001f) hy = 1.0f; + float cx = (min_x + max_x) * 0.5f; + float cy = (min_y + max_y) * 0.5f; + + shape_t s; + memset(&s, 0, sizeof(s)); + s.cx = cx; + s.cy = cy; + s.sx = hx; + s.sy = hy; + s.rotation = 0.0f; + shape_init_common(&s); + s.closed = true; + s.ctrl_count = n; + s.ctrl_points = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + s.ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + s.ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + + // Convert world-space points to local space, set handles + for (int i = 0; i < n; i++) { + float lx = (wpts[i].x - cx) / hx; + float ly = (wpts[i].y - cy) / hy; + s.ctrl_points[i] = (shape_vertex_t){lx, ly}; + + // Sharp corners by default: handles at anchor position + s.ctrl_handle_in[i] = (shape_vertex_t){lx, ly}; + s.ctrl_handle_out[i] = (shape_vertex_t){lx, ly}; + } + + strncpy(s.name, "Path", sizeof(s.name) - 1); + shape_regenerate_from_ctrl(&ud->shape_pool, &s); + *((shape_t*) vec_push(&ud->shapes)) = s; + + // History + { + int idx = ud->shapes.count - 1; + shape_t *sp = (shape_t*) vec_get(&ud->shapes, idx); + hist_batch_t batch; + history_batch_init(&batch, 1); + history_batch_add_shape(&batch, idx, HIST_CREATE, sp); + history_batch_commit(&batch, &ud->history); + } + + ud->pen.drawing = false; + ud->pen.point_count = 0; + ud->pen.preview_count = 0; + spatial_mark_dirty(&ud->spatial_grid); + overlay_invalidate(ud); +} + static void handle_left_down_ctrl_click(userdata_t *ud, float wx, float wy, float tol) { - for (int i = 0; i < ud->shapes.count; i++) { + int i = spatial_query_point(&ud->spatial_grid, &ud->shapes, wx, wy, tol); + if (i >= 0) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (shape_hit_test(s, wx, wy, tol)) { - if (ud->interact.focused_group_id != 0) { - s->selected = !s->selected; - ud->interact.selected_count += s->selected ? 1 : -1; - } else if (s->group_id != 0) { - int topmost = get_topmost_group(&ud->groups, s->group_id); - toggle_group_recursive(ud, topmost); - } else { - s->selected = !s->selected; - ud->interact.selected_count += s->selected ? 1 : -1; - } - ud->overlay_upload_needed = true; - update_shape_states(ud); - break; - } + s->selected = !s->selected; + ud->interact.selected_count += s->selected ? 1 : -1; + overlay_invalidate(ud); + update_shape_states(ud); } } @@ -124,13 +283,8 @@ static void handle_left_down_select_or_marquee(userdata_t *ud, const sapp_event ud->interact.select.current_x = event->mouse_x; ud->interact.select.current_y = event->mouse_y; - ud->interact.select.clicked_shape = -1; - for (int i = 0; i < ud->shapes.count; i++) { - if (shape_hit_test((shape_t*) vec_get(&ud->shapes, i), wx, wy, tol)) { - ud->interact.select.clicked_shape = i; - break; - } - } + ud->interact.select.clicked_shape = + spatial_query_point(&ud->spatial_grid, &ud->shapes, wx, wy, tol); } static void handle_right_down_pan_begin(userdata_t *ud, const sapp_event *event) @@ -177,7 +331,7 @@ static void handle_resize_end(userdata_t *ud) update_shape_states(ud); spatial_mark_dirty(&ud->spatial_grid); ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); } static void handle_rotate_end(userdata_t *ud) @@ -216,7 +370,7 @@ static void handle_rotate_end(userdata_t *ud) update_shape_states(ud); spatial_mark_dirty(&ud->spatial_grid); ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); } static void handle_move_end(userdata_t *ud) @@ -242,54 +396,30 @@ static void handle_move_end(userdata_t *ud) update_shape_states(ud); spatial_mark_dirty(&ud->spatial_grid); ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); } static void handle_select_end(userdata_t *ud) { if (!ud->interact.select.dragging) { if (ud->interact.select.clicked_shape >= 0) { - shape_t *clicked = (shape_t*) vec_get(&ud->shapes, ud->interact.select.clicked_shape); - for (int i = 0; i < ud->shapes.count; i++) ((shape_t*) vec_get(&ud->shapes, i))->selected = false; ud->interact.selected_count = 0; - - if (ud->interact.focused_group_id != 0) { - if (is_shape_in_group_hierarchy(clicked->group_id, ud->interact.focused_group_id, &ud->groups)) { - clicked->selected = true; - ud->interact.selected_count = 1; - } else { - ud->interact.focused_group_id = 0; - if (clicked->group_id != 0) { - int topmost = get_topmost_group(&ud->groups, clicked->group_id); - deselect_and_select_group_recursive(ud, topmost); - } else { - clicked->selected = true; - ud->interact.selected_count = 1; - } - } - } else if (clicked->group_id != 0) { - int topmost = get_topmost_group(&ud->groups, clicked->group_id); - deselect_and_select_group_recursive(ud, topmost); - } else { - clicked->selected = true; - ud->interact.selected_count = 1; - } + shape_t *clicked = (shape_t*) vec_get(&ud->shapes, ud->interact.select.clicked_shape); + clicked->selected = true; + ud->interact.selected_count = 1; } else { for (int i = 0; i < ud->shapes.count; i++) ((shape_t*) vec_get(&ud->shapes, i))->selected = false; ud->interact.selected_count = 0; - - if (ud->interact.focused_group_id != 0) - ud->interact.focused_group_id = 0; } } ud->interact.select.active = false; ud->interact.select.dragging = false; update_shape_states(ud); - ud->overlay_upload_needed = true; + overlay_invalidate(ud); } static void handle_pan_drag(userdata_t *ud, const sapp_event *event) @@ -319,17 +449,17 @@ static void handle_resize_drag(userdata_t *ud, const sapp_event *event) float scale_x = 1.0f, scale_y = 1.0f; if (ud->interact.resize.mask_x && fabsf(ini->ext_x) >= 0.0001f) - scale_x = fabsf(cex / ini->ext_x); + scale_x = cex / ini->ext_x; if (ud->interact.resize.mask_y && fabsf(ini->ext_y) >= 0.0001f) - scale_y = fabsf(cey / ini->ext_y); + scale_y = 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); + shape_regenerate(&ud->shape_pool, s); + shape_set_state(&ud->shape_pool, s, false, true); sx_total = scale_x; sy_total = scale_y; @@ -363,8 +493,8 @@ static void handle_rotate_drag(userdata_t *ud, const sapp_event *event) 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); + shape_regenerate(&ud->shape_pool, s); + shape_set_state(&ud->shape_pool, s, false, true); } ud->interact.rotate.total_delta = delta; @@ -384,8 +514,8 @@ static void handle_move_drag(userdata_t *ud, const sapp_event *event) shape_t *s = (shape_t*) vec_get(&ud->shapes, i); s->cx += delta_x; s->cy += delta_y; - shape_retranslate(s); - shape_set_state(s, false, true); + shape_retranslate(&ud->shape_pool, s); + shape_set_state(&ud->shape_pool, s, false, true); } ud->interact.move.total_dx = dx; @@ -398,7 +528,7 @@ static void handle_marquee_drag(userdata_t *ud, const sapp_event *event) ud->interact.select.current_y = event->mouse_y; float dx = ud->interact.select.current_x - ud->interact.select.start_x; float dy = ud->interact.select.current_y - ud->interact.select.start_y; - if (dx * dx + dy * dy > 9.0f) { + if (dx * dx + dy * dy > DRAG_THRESHOLD_SQ) { ud->interact.select.dragging = true; } @@ -413,38 +543,9 @@ static void handle_marquee_drag(userdata_t *ud, const sapp_event *event) &ud->spatial_grid, &ud->shapes, min_x, min_y, max_x, max_y); - if (ud->interact.focused_group_id == 0) { - int cap = ud->shapes.count; - int *gids = (int*) ALLOC((size_t)cap * sizeof(int)); - int n_gids = 0; - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (!s->selected || s->group_id == 0) continue; - int topmost = get_topmost_group(&ud->groups, s->group_id); - bool found = false; - for (int j = 0; j < n_gids; j++) { - if (gids[j] == topmost) { found = true; break; } - } - if (!found) gids[n_gids++] = topmost; - } - for (int j = 0; j < n_gids; j++) { - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (is_shape_in_group_hierarchy(s->group_id, gids[j], &ud->groups)) - s->selected = true; - } - } - FREE(gids); - ud->interact.selected_count = 0; - for (int i = 0; i < ud->shapes.count; i++) { - if (((shape_t*) vec_get(&ud->shapes, i))->selected) - ud->interact.selected_count++; - } - } - for (int i = 0; i < ud->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - shape_set_state(s, false, s->selected); + shape_set_state(&ud->shape_pool, s, false, s->selected); } } } @@ -457,25 +558,15 @@ static void handle_hover(userdata_t *ud, const sapp_event *event) int hovered = spatial_query_point(&ud->spatial_grid, &ud->shapes, wx, wy, tol); + if (hovered != ud->interact.hovered_shape) { ud->interact.hovered_shape = hovered; - EM_ASM({ document.querySelector('canvas').style.cursor = $0 ? 'pointer' : 'default'; }, hovered >= 0); - } - - int hovered_gid = 0; - if (hovered >= 0) { - shape_t *hs = (shape_t*) vec_get(&ud->shapes, hovered); - hovered_gid = hs->group_id; - if (hovered_gid != 0 && ud->interact.focused_group_id == 0) - hovered_gid = get_topmost_group(&ud->groups, hovered_gid); + EM_ASM({ Module._cartograph_canvas.style.cursor = $0 ? 'pointer' : 'default'; }, hovered >= 0); } for (int i = 0; i < ud->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - bool in_group = (ud->interact.focused_group_id == 0 && - hovered_gid != 0 && - is_shape_in_group_hierarchy(s->group_id, hovered_gid, &ud->groups)); - shape_set_state(s, (i == hovered || in_group), s->selected); + shape_set_state(&ud->shape_pool, s, (i == hovered), s->selected); } } @@ -484,10 +575,21 @@ static void handle_hover(userdata_t *ud, const sapp_event *event) static shape_t clipboard_deep_copy_shape(const shape_t *src) { shape_t dst = *src; + dst.ctrl_points = NULL; + dst.ctrl_handle_in = NULL; + dst.ctrl_handle_out = NULL; dst.verts = (shape_vertex_t*) ALLOC((size_t)src->num_elements * sizeof(shape_vertex_t)); dst.indices = (uint16_t*) ALLOC((size_t)src->num_elements * sizeof(uint16_t)); memcpy(dst.verts, src->verts, (size_t)src->num_elements * sizeof(shape_vertex_t)); memcpy(dst.indices, src->indices, (size_t)src->num_elements * sizeof(uint16_t)); + if (src->ctrl_count > 0) { + dst.ctrl_points = (shape_vertex_t*) ALLOC((size_t)src->ctrl_count * sizeof(shape_vertex_t)); + dst.ctrl_handle_in = (shape_vertex_t*) ALLOC((size_t)src->ctrl_count * sizeof(shape_vertex_t)); + dst.ctrl_handle_out = (shape_vertex_t*) ALLOC((size_t)src->ctrl_count * sizeof(shape_vertex_t)); + memcpy(dst.ctrl_points, src->ctrl_points, (size_t)src->ctrl_count * sizeof(shape_vertex_t)); + memcpy(dst.ctrl_handle_in, src->ctrl_handle_in, (size_t)src->ctrl_count * sizeof(shape_vertex_t)); + memcpy(dst.ctrl_handle_out, src->ctrl_handle_out, (size_t)src->ctrl_count * sizeof(shape_vertex_t)); + } return dst; } @@ -496,20 +598,14 @@ static void clipboard_clear(clipboard_t *cb) for (int i = 0; i < cb->shape_count; i++) { FREE(cb->shapes[i].verts); FREE(cb->shapes[i].indices); + FREE(cb->shapes[i].ctrl_points); + FREE(cb->shapes[i].ctrl_handle_in); + FREE(cb->shapes[i].ctrl_handle_out); } FREE(cb->shapes); - FREE(cb->groups); memset(cb, 0, sizeof(*cb)); } -static int clipboard_lookup_gid(int old_id, const int *map, int map_count) -{ - for (int j = 0; j < map_count; j++) { - if (map[j * 2] == old_id) return map[j * 2 + 1]; - } - return 0; -} - static void handle_copy(userdata_t *ud) { if (ud->interact.selected_count == 0) return; @@ -525,35 +621,11 @@ static void handle_copy(userdata_t *ud) ud->clipboard.shapes = (shape_t*) ALLOC((size_t)sel * sizeof(shape_t)); ud->clipboard.shape_count = 0; - int *gids = (int*) ALLOC((size_t)n * sizeof(int)); - int n_gids = 0; - for (int i = 0; i < n; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); if (!s->selected) continue; - ud->clipboard.shapes[ud->clipboard.shape_count++] = clipboard_deep_copy_shape(s); - - int gid = s->group_id; - while (gid != 0) { - bool found = false; - for (int j = 0; j < n_gids; j++) { - if (gids[j] == gid) { found = true; break; } - } - if (!found) gids[n_gids++] = gid; - group_t *g = find_group(&ud->groups, gid); - gid = g ? g->parent_id : 0; - } } - - ud->clipboard.groups = (group_t*) ALLOC((size_t)n_gids * sizeof(group_t)); - ud->clipboard.group_count = n_gids; - for (int j = 0; j < n_gids; j++) { - group_t *src = find_group(&ud->groups, gids[j]); - ud->clipboard.groups[j] = *src; - } - - FREE(gids); } static void handle_paste(userdata_t *ud) @@ -561,23 +633,6 @@ static void handle_paste(userdata_t *ud) clipboard_t *cb = &ud->clipboard; if (cb->shape_count == 0) return; - int gc = cb->group_count; - int *gid_map = NULL; - if (gc > 0) { - gid_map = (int*) ALLOC((size_t)gc * 2 * sizeof(int)); - for (int j = 0; j < gc; j++) { - gid_map[j * 2] = cb->groups[j].id; - gid_map[j * 2 + 1] = ud->next_group_id++; - } - for (int j = 0; j < gc; j++) { - group_t g = cb->groups[j]; - g.id = clipboard_lookup_gid(g.id, gid_map, gc); - g.parent_id = clipboard_lookup_gid(g.parent_id, gid_map, gc); - *((group_t*) vec_push(&ud->groups)) = g; - } - group_index_rebuild(&ud->groups); - } - for (int i = 0; i < ud->shapes.count; i++) ((shape_t*) vec_get(&ud->shapes, i))->selected = false; ud->interact.selected_count = 0; @@ -616,26 +671,23 @@ static void handle_paste(userdata_t *ud) shape_t s = clipboard_deep_copy_shape(&cb->shapes[i]); s.cx += cx - cb_cx; s.cy += cy - cb_cy; - if (gc > 0) - s.group_id = clipboard_lookup_gid(s.group_id, gid_map, gc); - else - s.group_id = 0; + s.group_id = 0; s.selected = true; ud->interact.selected_count++; + shape_retranslate(&ud->shape_pool, &s); *((shape_t*) vec_push(&ud->shapes)) = s; - g_shape_pool_dirty = true; + ud->shape_pool.pool_dirty = true; history_batch_add_shape(&batch, ud->shapes.count - 1, HIST_CREATE, (shape_t*) vec_get(&ud->shapes, ud->shapes.count - 1)); } history_batch_commit(&batch, &ud->history); - FREE(gid_map); + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); spatial_mark_dirty(&ud->spatial_grid); ud->interact.aabb_cached = false; - ud->interact.focused_group_id = 0; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); update_shape_states(ud); } @@ -645,24 +697,18 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) { if (event->modifiers & SAPP_MODIFIER_CTRL) { if (event->key_code == SAPP_KEYCODE_Z || event->key_code == SAPP_KEYCODE_W) { - if (history_undo(&ud->history, &ud->shapes)) { - rebuild_groups_from_shapes(&ud->groups, &ud->shapes); - ud->interact.hovered_shape = -1; - g_shape_pool_dirty = true; - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + if (history_undo(&ud->history, &ud->shapes, &ud->shape_pool, + &ud->groups, &ud->group_idx)) { + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); + interact_structural_change(ud); } return true; } if (event->key_code == SAPP_KEYCODE_Y) { - if (history_redo(&ud->history, &ud->shapes)) { - rebuild_groups_from_shapes(&ud->groups, &ud->shapes); - ud->interact.hovered_shape = -1; - g_shape_pool_dirty = true; - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + if (history_redo(&ud->history, &ud->shapes, &ud->shape_pool, + &ud->groups, &ud->group_idx)) { + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); + interact_structural_change(ud); } return true; } @@ -694,7 +740,7 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) int *parents = (int*) ALLOC((size_t)n_gids * sizeof(int)); for (int j = 0; j < n_gids; j++) { - group_t *grp = find_group(&ud->groups, gids[j]); + group_t *grp = find_group(&ud->group_idx, &ud->groups, gids[j]); parents[j] = grp ? grp->parent_id : 0; } @@ -707,7 +753,7 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) } } hist_batch_t batch; - history_batch_init(&batch, touched); + history_batch_init(&batch, touched + n_gids); for (int i = 0; i < ud->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); if (s->group_id == 0) continue; @@ -716,13 +762,6 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) for (int j = 0; j < n_gids; j++) { if (old_gid == gids[j]) { parent = parents[j]; break; } } - if (old_gid != 0 && parent == 0) { - bool in_touched = false; - for (int j = 0; j < n_gids; j++) { - if (old_gid == gids[j]) { in_touched = true; break; } - } - if (!in_touched) continue; - } bool in_touched = false; for (int j = 0; j < n_gids; j++) { if (old_gid == gids[j]) { in_touched = true; break; } @@ -733,6 +772,13 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) (float[4]){ (float)parent }); s->group_id = parent; } + for (int j = 0; j < n_gids; j++) { + hist_change_t *gc = &batch.changes[batch.count++]; + gc->shape_index = -1; + gc->prop = HIST_GROUP_DELETE; + gc->old_val[0] = (float)gids[j]; + gc->old_val[1] = (float)parents[j]; + } history_batch_commit(&batch, &ud->history); for (int j = 0; j < n_gids; j++) { @@ -747,18 +793,20 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) for (int g = 0; g < ud->groups.count; g++) { group_t *grp = (group_t*) vec_get(&ud->groups, g); if (grp->id == gids[j]) { + if (grp->member_indices) FREE(grp->member_indices); vec_remove_ordered(&ud->groups, g); break; } } } - group_index_rebuild(&ud->groups); + group_index_rebuild(&ud->group_idx, &ud->groups); + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); FREE(parents); FREE(gids); ud->ui.list_last_shape = -1; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); update_shape_states(ud); } else { // Group selected shapes @@ -766,101 +814,43 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) int gid = ud->next_group_id++; int n = ud->shapes.count; - int *full_gids = (int*) ALLOC((size_t)ud->groups.count * sizeof(int)); - int n_full = 0; - for (int g = 0; g < ud->groups.count; g++) { - group_t *grp = (group_t*) vec_get(&ud->groups, g); - bool all_sel = false; - int member_count = 0; - for (int i = 0; i < n; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->group_id == grp->id) { member_count++; if (!s->selected) break; } - } - if (member_count > 0) { - all_sel = true; - for (int i = 0; i < n; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->group_id == grp->id && !s->selected) { all_sel = false; break; } - } - } - if (!all_sel) continue; - bool parent_full = false; - if (grp->parent_id != 0) { - group_t *pg = find_group(&ud->groups, grp->parent_id); - if (pg) { - bool p_all_sel = true; - for (int i = 0; i < n; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->group_id == pg->id && !s->selected) { p_all_sel = false; break; } - } - if (p_all_sel) parent_full = true; - } - } - if (!parent_full) - full_gids[n_full++] = grp->id; - } - int touched = 0; for (int i = 0; i < n; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (!s->selected) continue; - bool in_full = false; - for (int j = 0; j < n_full; j++) { - if (s->group_id == full_gids[j]) { in_full = true; break; } - } - if (!in_full) touched++; + if (s->selected) touched++; } hist_batch_t batch; - history_batch_init(&batch, touched); + history_batch_init(&batch, touched + 1); for (int i = 0; i < n; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); if (!s->selected) continue; - bool in_full = false; - for (int j = 0; j < n_full; j++) { - if (s->group_id == full_gids[j]) { in_full = true; break; } - } - if (in_full) continue; history_batch_add(&batch, i, HIST_GROUP, (float[4]){ (float)s->group_id }, (float[4]){ (float)gid }); s->group_id = gid; } + { + hist_change_t *gc = &batch.changes[batch.count++]; + gc->shape_index = -1; + gc->prop = HIST_GROUP_CREATE; + gc->new_val[0] = (float)gid; + gc->new_val[1] = 0.0f; + } history_batch_commit(&batch, &ud->history); - group_t new_grp = { .id = gid, .parent_id = 0 }; + group_t new_grp = { .id = gid, .parent_id = 0, .collapsed = false }; *((group_t*) vec_push(&ud->groups)) = new_grp; - group_index_rebuild(&ud->groups); + group_index_rebuild(&ud->group_idx, &ud->groups); + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); - for (int j = 0; j < n_full; j++) { - for (int g = 0; g < ud->groups.count; g++) { - group_t *grp = (group_t*) vec_get(&ud->groups, g); - if (grp->id == full_gids[j]) { - grp->parent_id = gid; - break; - } - } - } - - FREE(full_gids); ud->ui.list_last_shape = -1; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); update_shape_states(ud); } return true; } } - if (event->key_code == SAPP_KEYCODE_ESCAPE) { - if (ud->interact.focused_group_id != 0) { - ud->interact.focused_group_id = 0; - for (int i = 0; i < ud->shapes.count; i++) - ((shape_t*) vec_get(&ud->shapes, i))->selected = false; - ud->interact.selected_count = 0; - ud->overlay_upload_needed = true; - update_shape_states(ud); - } - return true; - } if (event->key_code == SAPP_KEYCODE_GRAVE_ACCENT) { ud->ui.log_show = !ud->ui.log_show; return true; @@ -887,22 +877,59 @@ static bool handle_key_down(userdata_t *ud, const sapp_event *event) for (int j = collected - 1; j >= 0; j--) { shape_t *s = (shape_t*) vec_get(&ud->shapes, indices[j]); - shape_shutdown(s); + shape_shutdown(&ud->shape_pool, s); vec_remove_ordered(&ud->shapes, indices[j]); } - g_shape_pool_dirty = true; + ud->shape_pool.pool_dirty = true; FREE(indices); ud->interact.selected_count = 0; - ud->interact.hovered_shape = -1; - ud->interact.aabb_cached = false; - spatial_mark_dirty(&ud->spatial_grid); - ud->overlay_upload_needed = true; + + for (int g = ud->groups.count - 1; g >= 0; g--) { + group_t *grp = (group_t*) vec_get(&ud->groups, g); + int members = 0; + for (int i = 0; i < ud->shapes.count; i++) { + if (((shape_t*) vec_get(&ud->shapes, i))->group_id == grp->id) + members++; + } + if (members == 0) { + if (grp->member_indices) FREE(grp->member_indices); + vec_remove_ordered(&ud->groups, g); + } + } + group_index_rebuild(&ud->group_idx, &ud->groups); + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); + interact_structural_change(ud); update_shape_states(ud); } return true; } + + if (event->key_code == SAPP_KEYCODE_ESCAPE) { + if (ud->pen.drawing) { + ud->pen.drawing = false; + ud->pen.point_count = 0; + ud->pen.preview_count = 0; + overlay_invalidate(ud); + return true; + } + if (ud->interact.editing_shape_idx >= 0) { + ud->interact.editing_shape_idx = -1; + overlay_invalidate(ud); + return true; + } + return true; + } + + if (event->key_code == SAPP_KEYCODE_ENTER) { + if (ud->pen.drawing && ud->pen.point_count >= 2) { + pen_finish_shape(ud); + return true; + } + return true; + } + return false; } @@ -923,21 +950,79 @@ static void handle_mouse_down(userdata_t *ud, const sapp_event *event) screen_to_world(&ud->camera, event->mouse_x, event->mouse_y, &wx, &wy); const float tol = 4.0f / ud->camera.zoom; + // Pen tool: click to add points, close path near first point + if (ud->ui.active_tool == TOOL_PEN && !(event->modifiers & SAPP_MODIFIER_CTRL)) { + if (!ud->pen.drawing) { + ud->pen.drawing = true; + ud->pen.point_count = 0; + ud->pen.points[ud->pen.point_count++] = (shape_vertex_t){wx, wy}; + pen_update_preview(ud); + overlay_invalidate(ud); + } else { + // Check if clicking near the first point to close the path + float dx = wx - ud->pen.points[0].x; + float dy = wy - ud->pen.points[0].y; + float close_dist = PEN_CLOSE_PX / ud->camera.zoom; + if (ud->pen.point_count >= 3 && dx * dx + dy * dy <= close_dist * close_dist) { + pen_finish_shape(ud); + } else if (ud->pen.point_count < PEN_MAX_CONTROL_POINTS) { + ud->pen.points[ud->pen.point_count++] = (shape_vertex_t){wx, wy}; + pen_update_preview(ud); + overlay_invalidate(ud); + } + } + return; + } + + // Edit mode: drag anchors/handles, or click empty space to exit + if (ud->interact.editing_shape_idx >= 0) { + int anchor_idx; + bool is_handle, is_in; + int kind = hit_test_edit_point(ud, wx, wy, &anchor_idx, &is_handle, &is_in); + if (kind >= 0) { + // Snapshot old ctrl state for undo + shape_t *es = (shape_t*) vec_get(&ud->shapes, ud->interact.editing_shape_idx); + int n = es->ctrl_count; + ud->interact.edit_dragging = true; + ud->interact.edit_drag_idx = anchor_idx; + if (is_handle) { + ud->interact.edit_handle_dragging = true; + ud->interact.edit_handle_idx = anchor_idx; + ud->interact.edit_handle_is_in = is_in; + } else { + ud->interact.edit_handle_dragging = false; + } + if (ud->interact.edit_saved_ctrl) { FREE(ud->interact.edit_saved_ctrl); FREE(ud->interact.edit_saved_hin); FREE(ud->interact.edit_saved_hout); } + ud->interact.edit_saved_count = n; + ud->interact.edit_saved_ctrl = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + ud->interact.edit_saved_hin = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + ud->interact.edit_saved_hout = (shape_vertex_t*) ALLOC((size_t)n * sizeof(shape_vertex_t)); + memcpy(ud->interact.edit_saved_ctrl, es->ctrl_points, (size_t)n * sizeof(shape_vertex_t)); + memcpy(ud->interact.edit_saved_hin, es->ctrl_handle_in, (size_t)n * sizeof(shape_vertex_t)); + memcpy(ud->interact.edit_saved_hout, es->ctrl_handle_out, (size_t)n * sizeof(shape_vertex_t)); + } else { + // Clicked empty space — exit edit mode + ud->interact.editing_shape_idx = -1; + overlay_invalidate(ud); + } + return; + } + if (!(event->modifiers & SAPP_MODIFIER_CTRL) && ud->ui.active_tool >= TOOL_CIRCLE) { shape_t s; switch (ud->ui.active_tool) { case TOOL_CIRCLE: - s = shape_circle(wx, wy, 100.0f); + s = shape_circle(&ud->shape_pool, wx, wy, 100.0f); break; case TOOL_RECTANGLE: - s = shape_rectangle(wx, wy, 200.0f, 100.0f); + s = shape_rectangle(&ud->shape_pool, wx, wy, 200.0f, 100.0f); break; default: return; } *((shape_t*) vec_push(&ud->shapes)) = s; spatial_mark_dirty(&ud->spatial_grid); - ud->overlay_upload_needed = true; + overlay_invalidate(ud); { int idx = ud->shapes.count - 1; @@ -950,17 +1035,12 @@ static void handle_mouse_down(userdata_t *ud, const sapp_event *event) return; } - // Double-click detection for focus mode + // Double-click detection for edit mode { - int hit_shape = -1; - for (int i = 0; i < ud->shapes.count; i++) { - if (shape_hit_test((shape_t*) vec_get(&ud->shapes, i), wx, wy, tol)) { - hit_shape = i; - break; - } - } + int hit_shape = spatial_query_point(&ud->spatial_grid, + &ud->shapes, wx, wy, tol); - bool dbl = (ud->time - ud->interact.last_click_time < 0.3 && + bool dbl = (ud->time - ud->interact.last_click_time < DOUBLE_CLICK_TIME && hit_shape >= 0 && hit_shape == ud->interact.last_click_shape_idx); @@ -969,18 +1049,9 @@ static void handle_mouse_down(userdata_t *ud, const sapp_event *event) if (dbl && hit_shape >= 0) { shape_t *s = (shape_t*) vec_get(&ud->shapes, hit_shape); - if (s->group_id != 0 && ud->ui.active_tool == TOOL_SELECT) { - int gid = s->group_id; - if (ud->interact.focused_group_id != 0 && - is_shape_in_group_hierarchy(s->group_id, ud->interact.focused_group_id, &ud->groups)) { - gid = s->group_id; - } - for (int i = 0; i < ud->shapes.count; i++) - ((shape_t*) vec_get(&ud->shapes, i))->selected = false; - ud->interact.selected_count = 0; - ud->interact.focused_group_id = gid; - ud->overlay_upload_needed = true; - update_shape_states(ud); + if (s->ctrl_count > 0) { + ud->interact.editing_shape_idx = hit_shape; + overlay_invalidate(ud); return; } } @@ -1003,14 +1074,10 @@ static void handle_mouse_down(userdata_t *ud, const sapp_event *event) if (on_handle) { handle_left_down_rotate_begin(ud, wx, wy); } else { - int clicked_selected = -1; - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->selected && shape_hit_test(s, wx, wy, tol)) { - clicked_selected = i; - break; - } - } + int hit = spatial_query_point(&ud->spatial_grid, + &ud->shapes, wx, wy, tol); + int clicked_selected = (hit >= 0 && + ((shape_t*) vec_get(&ud->shapes, hit))->selected) ? hit : -1; bool in_aabb = false; if (clicked_selected < 0 && ud->interact.selected_count >= 2) { @@ -1046,6 +1113,33 @@ static void handle_mouse_up(userdata_t *ud, const sapp_event *event) { (void)event; + // Edit mode drag end — push a history entry for the vertex edit + if (ud->interact.edit_dragging) { + int ei = ud->interact.editing_shape_idx; + if (ei >= 0 && ei < ud->shapes.count && ud->interact.edit_saved_ctrl) { + shape_t *es = (shape_t*) vec_get(&ud->shapes, ei); + hist_batch_t batch; + history_batch_init(&batch, 1); + history_batch_add_edit(&batch, ei, es, + ud->interact.edit_saved_ctrl, + ud->interact.edit_saved_hin, + ud->interact.edit_saved_hout, + ud->interact.edit_saved_count); + history_batch_commit(&batch, &ud->history); + } + FREE(ud->interact.edit_saved_ctrl); + FREE(ud->interact.edit_saved_hin); + FREE(ud->interact.edit_saved_hout); + ud->interact.edit_saved_ctrl = NULL; + ud->interact.edit_saved_hin = NULL; + ud->interact.edit_saved_hout = NULL; + ud->interact.edit_saved_count = 0; + ud->interact.edit_dragging = false; + ud->interact.edit_handle_dragging = false; + overlay_invalidate(ud); + return; + } + if (ud->interact.resize.dragging) { handle_resize_end(ud); } else if (ud->interact.rotate.dragging) { @@ -1064,6 +1158,55 @@ static void handle_mouse_move(userdata_t *ud, const sapp_event *event) ud->mouse_x = event->mouse_x; ud->mouse_y = event->mouse_y; + // Edit mode drag + if (ud->interact.edit_dragging && ud->interact.editing_shape_idx >= 0) { + int idx = ud->interact.editing_shape_idx; + if (idx < ud->shapes.count) { + shape_t *s = (shape_t*) vec_get(&ud->shapes, idx); + float wx, wy, lx, ly; + screen_to_world(&ud->camera, event->mouse_x, event->mouse_y, &wx, &wy); + world_to_local_shape(s, wx, wy, &lx, &ly); + + int ai = ud->interact.edit_drag_idx; + if (ud->interact.edit_handle_dragging) { + if (ud->interact.edit_handle_is_in) + s->ctrl_handle_in[ai] = (shape_vertex_t){lx, ly}; + else + s->ctrl_handle_out[ai] = (shape_vertex_t){lx, ly}; + } else { + // Move the anchor; handles follow by the same delta + float dlx = lx - s->ctrl_points[ai].x; + float dly = ly - s->ctrl_points[ai].y; + s->ctrl_points[ai] = (shape_vertex_t){lx, ly}; + s->ctrl_handle_in[ai].x += dlx; + s->ctrl_handle_in[ai].y += dly; + s->ctrl_handle_out[ai].x += dlx; + s->ctrl_handle_out[ai].y += dly; + } + shape_regenerate_from_ctrl(&ud->shape_pool, s); + overlay_invalidate(ud); + } + return; + } + + // Pen tool preview + if (ud->pen.drawing) { + // Preview already updated on mouse down; add live preview to last segment + if (ud->pen.point_count >= 1 && ud->pen.point_count < PEN_MAX_CONTROL_POINTS) { + float wx, wy; + screen_to_world(&ud->camera, event->mouse_x, event->mouse_y, &wx, &wy); + // Temporarily add the mouse position as the next point for preview + shape_vertex_t saved = ud->pen.points[ud->pen.point_count]; + ud->pen.points[ud->pen.point_count] = (shape_vertex_t){wx, wy}; + ud->pen.point_count++; + pen_update_preview(ud); + ud->pen.point_count--; + ud->pen.points[ud->pen.point_count] = saved; + overlay_invalidate(ud); + } + return; + } + if (ud->camera.pan_state.dragging) { handle_pan_drag(ud, event); } else if (ud->interact.resize.dragging) { @@ -1081,8 +1224,8 @@ static void handle_mouse_move(userdata_t *ud, const sapp_event *event) static void handle_scroll_zoom(userdata_t *ud, const sapp_event *event) { - if ((ud->camera.zoom >= 6.0f && event->scroll_y > 0.0f) || - (ud->camera.zoom <= 0.1f && event->scroll_y < 0.0f)) + if ((ud->camera.zoom >= CAMERA_ZOOM_MAX && event->scroll_y > 0.0f) || + (ud->camera.zoom <= CAMERA_ZOOM_MIN && event->scroll_y < 0.0f)) return; float wx, wy; @@ -1090,7 +1233,7 @@ static void handle_scroll_zoom(userdata_t *ud, const sapp_event *event) const float diff = expf(event->scroll_y * 0.1f); float new_zoom = ud->camera.zoom * diff; - ud->camera.zoom = new_zoom < 0.1f ? 0.1f : (new_zoom > 6.0f ? 6.0f : new_zoom); + ud->camera.zoom = new_zoom < CAMERA_ZOOM_MIN ? CAMERA_ZOOM_MIN : (new_zoom > CAMERA_ZOOM_MAX ? CAMERA_ZOOM_MAX : new_zoom); ud->camera.hover_tol = SHAPE_HOVER_PX / ud->camera.zoom; const float sx = event->mouse_x - ud->camera.half_width; @@ -1098,7 +1241,7 @@ static void handle_scroll_zoom(userdata_t *ud, const sapp_event *event) ud->camera.pan[0] = sx - wx * ud->camera.zoom; ud->camera.pan[1] = sy - wy * ud->camera.zoom; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); compute_mvp(&ud->camera, &ud->renderer.uniform.mvp); } diff --git a/src/interact.h b/src/interact.h index 0360207..ddd5069 100644 --- a/src/interact.h +++ b/src/interact.h @@ -4,6 +4,22 @@ #include "api.h" #include "types.h" +// Forward-declared: defined in overlay.h (included after interact.h in api.h). +static void overlay_invalidate(userdata_t *ud); + +// Called after any operation that changes shape count or structure (undo, +// redo, delete, paste, group, ungroup). Invalidates cached state so the next +// frame rebuilds the spatial grid, overlay geometry, and GPU instance data. +static void interact_structural_change(userdata_t *ud) +{ + ud->interact.hovered_shape = -1; + ud->shape_pool.pool_dirty = true; + spatial_mark_dirty(&ud->spatial_grid); + ud->interact.aabb_cached = false; + ud->ui.display_cache_dirty = true; + overlay_invalidate(ud); +} + static void selected_aabb(userdata_t *ud, float *min_x, float *min_y, float *max_x, float *max_y) { @@ -11,36 +27,33 @@ static void selected_aabb(userdata_t *ud, float *min_x, float *min_y, 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 = s->cos_r, ss = s->sin_r; - 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; - } + float smin_x = s->cx - s->aabb_hx; + float smin_y = s->cy - s->aabb_hy; + float smax_x = s->cx + s->aabb_hx; + float smax_y = s->cy + s->aabb_hy; + if (first) { + *min_x = smin_x; *min_y = smin_y; + *max_x = smax_x; *max_y = smax_y; + first = false; + } else { + if (smin_x < *min_x) *min_x = smin_x; + if (smin_y < *min_y) *min_y = smin_y; + if (smax_x > *max_x) *max_x = smax_x; + if (smax_y > *max_y) *max_y = smax_y; } } } static void update_shape_states(userdata_t *ud) { - for (int i = 0; i < ud->shapes.count; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - shape_set_state(s, s->hovered, s->selected); - } + ud->shape_pool.states_dirty = true; } static void select_group_recursive(userdata_t *ud, int gid) { for (int i = 0; i < ud->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (is_shape_in_group_hierarchy(s->group_id, gid, &ud->groups)) { + if (is_shape_in_group_hierarchy(&ud->group_idx,s->group_id, gid, &ud->groups)) { s->selected = true; ud->interact.selected_count++; } @@ -60,7 +73,7 @@ static void toggle_group_recursive(userdata_t *ud, int gid) int total = 0, sel = 0; for (int i = 0; i < ud->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (is_shape_in_group_hierarchy(s->group_id, gid, &ud->groups)) { + if (is_shape_in_group_hierarchy(&ud->group_idx,s->group_id, gid, &ud->groups)) { total++; if (s->selected) sel++; } @@ -68,58 +81,13 @@ static void toggle_group_recursive(userdata_t *ud, int gid) bool all_sel = (sel == total && total > 0); for (int i = 0; i < ud->shapes.count; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (is_shape_in_group_hierarchy(s->group_id, gid, &ud->groups)) { + if (is_shape_in_group_hierarchy(&ud->group_idx,s->group_id, gid, &ud->groups)) { s->selected = !all_sel; ud->interact.selected_count += s->selected ? 1 : -1; } } } -static void rebuild_groups_from_shapes(vector_t *groups, vector_t *shapes) -{ - // Save existing parent relationships so nested groups survive rebuild - int old_count = groups->count; - int *saved = NULL; - if (old_count > 0) { - saved = (int*) ALLOC((size_t)old_count * 2 * sizeof(int)); - for (int i = 0; i < old_count; i++) { - group_t *g = (group_t*) vec_get(groups, i); - saved[i * 2] = g->id; - saved[i * 2 + 1] = g->parent_id; - } - } - - groups->count = 0; - for (int i = 0; i < shapes->count; i++) { - shape_t *s = (shape_t*) vec_get(shapes, i); - if (s->group_id == 0) continue; - bool found = false; - for (int g = 0; g < groups->count; g++) { - if (((group_t*) vec_get(groups, g))->id == s->group_id) { found = true; break; } - } - if (!found) { - group_t grp = { .id = s->group_id, .parent_id = 0 }; - *((group_t*) vec_push(groups)) = grp; - } - } - - // Restore parent relationships for groups that still exist - for (int i = 0; i < old_count; i++) { - int gid = saved[i * 2]; - int pid = saved[i * 2 + 1]; - if (pid == 0) continue; - group_t *g = find_group(groups, gid); - if (g) { - // Only restore if parent group still exists - if (find_group(groups, pid)) - g->parent_id = pid; - } - } - - if (saved) FREE(saved); - group_index_rebuild(groups); -} - static int hit_test_resize_handles(userdata_t *ud, float wx, float wy, float tol) { if (ud->interact.selected_count <= 0) return -1; diff --git a/src/main.c b/src/main.c index 33aa624..f6dfa7d 100644 --- a/src/main.c +++ b/src/main.c @@ -27,6 +27,7 @@ static void log_capture(const char* tag, uint32_t log_level, uint32_t log_item, strncpy(ud->ui.log_ring[idx].text, buf, 255); ud->ui.log_ring[idx].text[255] = 0; ud->ui.log_ring[idx].level = log_level; + ud->ui.log_ring[idx].hash = 0; ud->ui.log_head = (idx + 1) % LOG_RING_SIZE; if (ud->ui.log_count < LOG_RING_SIZE) ud->ui.log_count++; @@ -34,8 +35,33 @@ static void log_capture(const char* tag, uint32_t log_level, uint32_t log_item, if (log_level <= 1) ud->ui.log_show = true; } +static uint64_t fnv1a_64(const char *msg) { + uint64_t h = 14695981039346656037ULL; + while (*msg) { + h ^= (uint64_t)(unsigned char)*msg++; + h *= 1099511628211ULL; + } + return h; +} + static void panel_log_impl(void *ud_v, int level, const char *msg) { userdata_t *ud = (userdata_t*)ud_v; + + // Use a 64-bit message hash to skip the O(n) strcmp scan for most + // non-matches. Debug-level messages (3) skip dedup entirely — they are + // expected to repeat and the linear scan cost isn't worth it. + if (level < 3) { + uint64_t h = fnv1a_64(msg); + int total = ud->ui.log_count < LOG_RING_SIZE ? ud->ui.log_count : LOG_RING_SIZE; + for (int i = 0; i < total; i++) { + if (ud->ui.log_ring[i].hash == h && + strcmp(ud->ui.log_ring[i].text, msg) == 0) return; + } + ud->ui.log_ring[ud->ui.log_head].hash = h; + } else { + ud->ui.log_ring[ud->ui.log_head].hash = 0; + } + int idx = ud->ui.log_head; strncpy(ud->ui.log_ring[idx].text, msg, 255); ud->ui.log_ring[idx].text[255] = 0; @@ -108,12 +134,12 @@ static void frame(void* _userdata) static void init(void* _userdata) { - rand_seed(1); - userdata_t* ud = (userdata_t*) _userdata; - g_panel_log_fn = panel_log_impl; - g_panel_log_ud = ud; + rand_seed(&ud->rand_ctx, 1); + + ud->panel_log_ctx.fn = panel_log_impl; + ud->panel_log_ctx.ud = ud; sg_desc sgdesc = { .environment = sglue_environment(), @@ -226,7 +252,7 @@ static void init(void* _userdata) }, }; - shape_init_pipeline(); + shape_init_pipeline(&ud->pipelines, &ud->panel_log_ctx); vec_init(&ud->shapes, sizeof(shape_t)); vec_init(&ud->groups, sizeof(group_t)); @@ -240,18 +266,22 @@ static void init(void* _userdata) ud->ui.left_panel_w = 220; ud->ui.list_last_shape = -1; ud->ui.list_prev_count = -1; + ud->ui.display_cache = NULL; + ud->ui.display_cache_len = 0; + ud->ui.display_cache_dirty = true; ud->interact.move.dragging = false; ud->interact.rotate.dragging = false; ud->interact.resize.dragging = false; ud->interact.resize.angle = 0.0f; ud->interact.resize.init = NULL; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); ud->interact.resize.init_count = 0; ud->next_group_id = 1; ud->time = 0.0; - ud->interact.focused_group_id = 0; ud->interact.last_click_time = 0.0; ud->interact.last_click_shape_idx = -1; + ud->map_w = 0; + ud->map_h = 0; ud->ui.log_head = 0; ud->ui.log_count = 0; ud->ui.log_show = true; @@ -302,20 +332,75 @@ static void init(void* _userdata) }); } - *((shape_t*) vec_push(&ud->shapes)) = shape_star(0.0f, 0.0f, 200.0f, 80.0f, 7); + *((shape_t*) vec_push(&ud->shapes)) = shape_circle(&ud->shape_pool, 300.0f, 0.0f, 120.0f); - *((shape_t*) vec_push(&ud->shapes)) = shape_circle(300.0f, 0.0f, 120.0f); + // Pen tool buffers + { + ud->pen_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = PEN_PREVIEW_MAX_VERTS * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Pen preview verts", + }); + uint16_t *pen_idx = (uint16_t*) ALLOC(PEN_PREVIEW_MAX_VERTS * sizeof(uint16_t)); + for (int i = 0; i < PEN_PREVIEW_MAX_VERTS; i++) pen_idx[i] = (uint16_t)i; + ud->pen_ibuf = sg_make_buffer(&(sg_buffer_desc){ + .usage = {.index_buffer = true}, + .data = {pen_idx, (size_t)PEN_PREVIEW_MAX_VERTS * sizeof(uint16_t)}, + .label = "Pen preview indices", + }); + FREE(pen_idx); + memset(&ud->pen, 0, sizeof(ud->pen)); + } + // Edit mode buffers + { + int amax = PEN_MAX_CONTROL_POINTS * 5; // anchors + int hmax = PEN_MAX_CONTROL_POINTS * 10; // handles (2 per anchor, 5 verts each) + int lmax = PEN_MAX_CONTROL_POINTS * 4; // lines (2 per anchor, 2 verts each) + + ud->ed_anchor_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = (size_t)amax * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Edit anchor verts", + }); + ud->ed_handle_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = (size_t)hmax * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Edit handle verts", + }); + ud->ed_handle_line_vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = (size_t)lmax * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Edit handle lines", + }); + + int ibmax = hmax > lmax ? hmax : lmax; + if (amax > ibmax) ibmax = amax; + uint16_t *ed_idx = (uint16_t*) ALLOC((size_t)ibmax * sizeof(uint16_t)); + for (int i = 0; i < ibmax; i++) ed_idx[i] = (uint16_t)i; + ud->ed_shared_ibuf = sg_make_buffer(&(sg_buffer_desc){ + .usage = {.index_buffer = true}, + .data = {ed_idx, (size_t)ibmax * sizeof(uint16_t)}, + .label = "Edit shared indices", + }); + FREE(ed_idx); + + ud->ed_anchor_count = 0; + ud->ed_handle_count = 0; + ud->ed_handle_line_count = 0; + ud->interact.editing_shape_idx = -1; + } history_init(&ud->history); EM_ASM({ window.addEventListener('keydown', function(e) { if (e.ctrlKey && !e.altKey && !e.metaKey) { - if (e.key === 'z' || e.key === 'y' || e.key === 'c' || e.key === 'v') { + if (e.key === 'z' || e.key === 'y' || e.key === 'c' || e.key === 'v' || e.key === 'g') { e.preventDefault(); } } }, true); + Module._cartograph_canvas = document.querySelector('canvas'); }); compute_mvp(&ud->camera, &ud->renderer.uniform.mvp); @@ -326,14 +411,16 @@ static void cleanup(void* _userdata) userdata_t* ud = (userdata_t*) _userdata; for (int i = 0; i < ud->shapes.count; i++) { - shape_shutdown((shape_t*) vec_get(&ud->shapes, i)); + shape_shutdown(&ud->shape_pool, (shape_t*) vec_get(&ud->shapes, i)); } spatial_destroy(&ud->spatial_grid); vec_free(&ud->shapes); + group_shutdown_members(&ud->groups); vec_free(&ud->groups); vec_free(&ud->interact.drag_indices); - group_index_shutdown(); + group_index_shutdown(&ud->group_idx); history_destroy(&ud->history); + if (ud->interact.edit_saved_ctrl) { FREE(ud->interact.edit_saved_ctrl); FREE(ud->interact.edit_saved_hin); FREE(ud->interact.edit_saved_hout); } if (ud->interact.resize.init) FREE(ud->interact.resize.init); sg_destroy_buffer(ud->rect_vbuf); sg_destroy_buffer(ud->rect_ibuf); @@ -341,17 +428,23 @@ static void cleanup(void* _userdata) sg_destroy_buffer(ud->handle_ibuf); sg_destroy_buffer(ud->corner_vbuf); sg_destroy_buffer(ud->corner_ibuf); + sg_destroy_buffer(ud->pen_vbuf); + sg_destroy_buffer(ud->pen_ibuf); + sg_destroy_buffer(ud->ed_anchor_vbuf); + sg_destroy_buffer(ud->ed_handle_vbuf); + sg_destroy_buffer(ud->ed_handle_line_vbuf); + sg_destroy_buffer(ud->ed_shared_ibuf); sg_destroy_pipeline(ud->renderer.pipeline); sg_destroy_shader(ud->renderer.shader); - shape_pool_shutdown(); - shape_shutdown_pipeline(); + shape_pool_shutdown(&ud->shape_pool); + shape_shutdown_pipeline(&ud->pipelines); for (int i = 0; i < ud->clipboard.shape_count; i++) { FREE(ud->clipboard.shapes[i].verts); FREE(ud->clipboard.shapes[i].indices); } FREE(ud->clipboard.shapes); - FREE(ud->clipboard.groups); + FREE(ud->ui.display_cache); FREE(ud); diff --git a/src/overlay.h b/src/overlay.h index c22a676..2e0aaeb 100644 --- a/src/overlay.h +++ b/src/overlay.h @@ -5,6 +5,11 @@ #include "types.h" #include "interact.h" +static void overlay_invalidate(userdata_t *ud) +{ + memset(&ud->overlay_upload, 1, sizeof(ud->overlay_upload)); +} + static void compute_overlay_geometry(userdata_t *ud, shape_vertex_t overlay_verts[5], float *sel_cx, float *sel_cy, float *sel_hw, float *sel_hh, float *sel_angle, @@ -14,6 +19,9 @@ static void compute_overlay_geometry(userdata_t *ud, *sel_cx = *sel_cy = *sel_angle = 0; *sel_hw = *sel_hh = 0; + // Suppress selection/resize/rotate overlay during vertex edit mode + if (ud->interact.editing_shape_idx >= 0) { *show_handle = false; return; } + if (ud->interact.select.active && ud->interact.select.dragging) { float wx1, wy1, wx2, wy2; screen_to_world(&ud->camera, ud->interact.select.start_x, ud->interact.select.start_y, &wx1, &wy1); @@ -51,10 +59,11 @@ static void compute_overlay_geometry(userdata_t *ud, shape_t *s = (shape_t*) vec_get(&ud->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(ud, &x1, &y1, &x2, &y2); + *sel_hw = (x2 - x1) * 0.5f; + *sel_hh = (y2 - y1) * 0.5f; ud->interact.cached_aabb[0] = x1; ud->interact.cached_aabb[1] = y1; ud->interact.cached_aabb[2] = x2; ud->interact.cached_aabb[3] = y2; ud->interact.aabb_cached = true; @@ -104,9 +113,14 @@ static void upload_overlay_buffers(userdata_t *ud, float sel_cx, float sel_cy, float sel_hw, float sel_hh, float sel_angle, bool has_overlay, bool show_handle) { - bool need_upload = ud->overlay_upload_needed || + bool need_upload = ud->overlay_upload.rect || ud->interact.move.dragging || ud->interact.rotate.dragging || - ud->interact.resize.dragging || ud->interact.select.active; + ud->interact.resize.dragging || ud->interact.select.active || + (ud->interact.editing_shape_idx >= 0); + + static shape_vertex_t ed_anchor_verts[128 * 5]; + static shape_vertex_t ed_handle_verts[256 * 5]; + static shape_vertex_t ed_line_verts[256 * 2]; if (has_overlay && need_upload) { sg_update_buffer(ud->rect_vbuf, &(sg_range){overlay_verts, (size_t)5 * sizeof(shape_vertex_t)}); @@ -166,7 +180,68 @@ static void upload_overlay_buffers(userdata_t *ud, } } - ud->overlay_upload_needed = false; + // Edit mode overlay + if (ud->interact.editing_shape_idx >= 0 && need_upload) { + shape_t *es = (shape_t*) vec_get(&ud->shapes, ud->interact.editing_shape_idx); + int n = es->ctrl_count; + if (n > 128) n = 128; + + float as = EDIT_ANCHOR_SIZE_PX / ud->camera.zoom * 0.5f; + float hs = EDIT_HANDLE_SIZE_PX / ud->camera.zoom * 0.5f; + + int ac = 0, hc = 0, lc = 0; + for (int i = 0; i < n; i++) { + shape_vertex_t wa = local_to_world(es, es->ctrl_points[i].x, es->ctrl_points[i].y); + // Anchor square + int ba = ac * 5; + ed_anchor_verts[ba+0] = (shape_vertex_t){wa.x - as, wa.y - as}; + ed_anchor_verts[ba+1] = (shape_vertex_t){wa.x + as, wa.y - as}; + ed_anchor_verts[ba+2] = (shape_vertex_t){wa.x + as, wa.y + as}; + ed_anchor_verts[ba+3] = (shape_vertex_t){wa.x - as, wa.y + as}; + ed_anchor_verts[ba+4] = (shape_vertex_t){wa.x - as, wa.y - as}; + ac++; + + shape_vertex_t wh_in = local_to_world(es, es->ctrl_handle_in[i].x, es->ctrl_handle_in[i].y); + shape_vertex_t wh_out = local_to_world(es, es->ctrl_handle_out[i].x, es->ctrl_handle_out[i].y); + + // Handle line: anchor → in handle + ed_line_verts[lc++] = wa; + ed_line_verts[lc++] = wh_in; + // Handle line: anchor → out handle + ed_line_verts[lc++] = wa; + ed_line_verts[lc++] = wh_out; + + // In handle square + int bh = hc * 5; + ed_handle_verts[bh+0] = (shape_vertex_t){wh_in.x - hs, wh_in.y - hs}; + ed_handle_verts[bh+1] = (shape_vertex_t){wh_in.x + hs, wh_in.y - hs}; + ed_handle_verts[bh+2] = (shape_vertex_t){wh_in.x + hs, wh_in.y + hs}; + ed_handle_verts[bh+3] = (shape_vertex_t){wh_in.x - hs, wh_in.y + hs}; + ed_handle_verts[bh+4] = (shape_vertex_t){wh_in.x - hs, wh_in.y - hs}; + hc++; + // Out handle square + bh = hc * 5; + ed_handle_verts[bh+0] = (shape_vertex_t){wh_out.x - hs, wh_out.y - hs}; + ed_handle_verts[bh+1] = (shape_vertex_t){wh_out.x + hs, wh_out.y - hs}; + ed_handle_verts[bh+2] = (shape_vertex_t){wh_out.x + hs, wh_out.y + hs}; + ed_handle_verts[bh+3] = (shape_vertex_t){wh_out.x - hs, wh_out.y + hs}; + ed_handle_verts[bh+4] = (shape_vertex_t){wh_out.x - hs, wh_out.y - hs}; + hc++; + } + + ud->ed_anchor_count = ac; + ud->ed_handle_count = hc; + ud->ed_handle_line_count = lc; + + if (ac > 0) + sg_update_buffer(ud->ed_anchor_vbuf, &(sg_range){ed_anchor_verts, (size_t)(ac * 5) * sizeof(shape_vertex_t)}); + if (hc > 0) + sg_update_buffer(ud->ed_handle_vbuf, &(sg_range){ed_handle_verts, (size_t)(hc * 5) * sizeof(shape_vertex_t)}); + if (lc > 0) + sg_update_buffer(ud->ed_handle_line_vbuf, &(sg_range){ed_line_verts, (size_t)lc * sizeof(shape_vertex_t)}); + } + + memset(&ud->overlay_upload, 0, sizeof(ud->overlay_upload)); } #endif diff --git a/src/rand.h b/src/rand.h index 33aff26..3d1a924 100644 --- a/src/rand.h +++ b/src/rand.h @@ -3,104 +3,114 @@ #include "api.h" -static uint32_t seed; +typedef struct { + uint32_t seed; +} rand_ctx_t; -static uint32_t xorshift32(void); -static void rand_seed(uint32_t _seed); -static uint32_t next_int(void); -static uint32_t next_int_max(uint32_t max); -static uint32_t next_int_minmax(uint32_t min, uint32_t max); -static float next_float(void); -static float next_float_max(float max); -static float next_float_minmax(float min, float max); +static uint32_t xorshift32(rand_ctx_t *r); +static void rand_seed(rand_ctx_t *r, uint32_t _seed); +static uint32_t next_int(rand_ctx_t *r); +static uint32_t next_int_max(rand_ctx_t *r, uint32_t max); +static uint32_t next_int_minmax(rand_ctx_t *r, uint32_t min, uint32_t max); +static float next_float(rand_ctx_t *r); +static float next_float_max(rand_ctx_t *r, float max); +static float next_float_minmax(rand_ctx_t *r, float min, float max); /** - * Xorshift32 PRNG core. Advances the global seed and returns the new value. + * Xorshift32 PRNG core. Advances the seed and returns the new value. * - * @return pseudo-random 32-bit integer + * @param r PRNG context + * @return pseudo-random 32-bit integer */ -static uint32_t xorshift32(void) +static uint32_t xorshift32(rand_ctx_t *r) { - seed ^= seed<<13; - seed ^= seed>>17; - seed ^= seed<<5; - return seed; + r->seed ^= r->seed<<13; + r->seed ^= r->seed>>17; + r->seed ^= r->seed<<5; + return r->seed; } /** - * Seed the global PRNG state. Zero is ignored (caller should pass a non-zero + * Seed the PRNG state. Zero is ignored (caller should pass a non-zero * seed). Runs the generator once after seeding to mix the state. * + * @param r PRNG context * @param _seed non-zero 32-bit seed value */ -static void rand_seed(uint32_t _seed) +static void rand_seed(rand_ctx_t *r, uint32_t _seed) { if(_seed == 0) return; - - seed = _seed; - xorshift32(); + + r->seed = _seed; + xorshift32(r); } /** * Return a random integer in [0, UINT32_MAX]. * - * @return pseudo-random 32-bit integer + * @param r PRNG context + * @return pseudo-random 32-bit integer */ -static uint32_t next_int(void) +static uint32_t next_int(rand_ctx_t *r) { - return xorshift32(); + return xorshift32(r); } /** * Return a random integer in [0, max]. * + * @param r PRNG context * @param max inclusive upper bound * @return pseudo-random integer */ -static uint32_t next_int_max(uint32_t max) +static uint32_t next_int_max(rand_ctx_t *r, uint32_t max) { - return (uint32_t)((double)xorshift32() / (double)UINT32_MAX * max); + return (uint32_t)((double)xorshift32(r) / (double)UINT32_MAX * max); } /** * Return a random integer in [min, max]. * + * @param r PRNG context * @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) +static uint32_t next_int_minmax(rand_ctx_t *r, uint32_t min, uint32_t max) { - const double x = (double)xorshift32() / (double)UINT32_MAX; + const double x = (double)xorshift32(r) / (double)UINT32_MAX; return (uint32_t)((1.0 - x) * min + x * max); } /** * Return a random float in [0, 1]. * - * @return pseudo-random float + * @param r PRNG context + * @return pseudo-random float */ -static float next_float(void) +static float next_float(rand_ctx_t *r) { - return (float)((double)xorshift32() / (double)UINT32_MAX); + return (float)((double)xorshift32(r) / (double)UINT32_MAX); } /** * Return a random float in [0, max]. * + * @param r PRNG context * @param max inclusive upper bound * @return pseudo-random float */ -static float next_float_max(float max) +static float next_float_max(rand_ctx_t *r, float max) { - return (float)((double)xorshift32() / (double)UINT32_MAX * max); + return (float)((double)xorshift32(r) / (double)UINT32_MAX * max); } /** * Return a random float in [min, max]. * + * @param r PRNG context * @param min inclusive lower bound * @param max inclusive upper bound * @return pseudo-random float */ -static float next_float_minmax(float min, float max) +static float next_float_minmax(rand_ctx_t *r, float min, float max) { - const double x = (double)xorshift32() / (double)UINT32_MAX; + const double x = (double)xorshift32(r) / (double)UINT32_MAX; return (float)((1.0 - x) * min + x * max); } -#endif \ No newline at end of file +#endif diff --git a/src/render.h b/src/render.h index b0a4ae0..2d56c23 100644 --- a/src/render.h +++ b/src/render.h @@ -3,10 +3,14 @@ #include "api.h" -static sg_pipeline shape_pipeline; -static sg_shader shape_shader; -static sg_pipeline overlay_pipeline; -static sg_shader overlay_shader; +// Pipeline state — was static globals, now owned by userdata_t. +typedef struct { + sg_pipeline shape_pipeline; + sg_shader shape_shader; + sg_pipeline overlay_pipeline; + sg_shader overlay_shader; +} pipeline_ctx_t; + static int g_shape_frame_id; static void shape_begin_frame(void) @@ -14,10 +18,12 @@ static void shape_begin_frame(void) g_shape_frame_id++; } -static void shape_init_pipeline(void) +// Pipeline state is owned by pipeline_ctx_t (embedded in userdata_t). +// Previously these were file-scope statics, which prevented multi-TU builds. +static void shape_init_pipeline(pipeline_ctx_t *p, panel_log_ctx_t *pl) { // Overlay shader/pipeline (simple, no storage buffers) - overlay_shader = sg_make_shader(&(sg_shader_desc) { + p->overlay_shader = sg_make_shader(&(sg_shader_desc) { .vertex_func = { .source = (const char*) src_shaders_overlay_wgsl, .entry = "vs_main", @@ -43,10 +49,12 @@ static void shape_init_pipeline(void) }, .label = "Overlay shader", }); - panel_log(3, "[shapes] overlay shader id=%d valid=%d", overlay_shader.id, sg_isvalid()); + panel_log_debug(pl, "[shapes] overlay shader id=%d valid=%d", p->overlay_shader.id, sg_isvalid()); + if (p->overlay_shader.id == SG_INVALID_ID) + panel_log(pl, 1, "[shapes] FAILED to create overlay shader"); - overlay_pipeline = sg_make_pipeline(&(sg_pipeline_desc) { - .shader = overlay_shader, + p->overlay_pipeline = sg_make_pipeline(&(sg_pipeline_desc) { + .shader = p->overlay_shader, .index_type = SG_INDEXTYPE_UINT16, .primitive_type = SG_PRIMITIVETYPE_LINE_STRIP, .layout.attrs = { @@ -54,10 +62,12 @@ static void shape_init_pipeline(void) }, .label = "Overlay pipeline", }); - panel_log(3, "[shapes] overlay pipeline id=%d valid=%d", overlay_pipeline.id, sg_isvalid()); + panel_log_debug(pl, "[shapes] overlay pipeline id=%d valid=%d", p->overlay_pipeline.id, sg_isvalid()); + if (p->overlay_pipeline.id == SG_INVALID_ID) + panel_log(pl, 1, "[shapes] FAILED to create overlay pipeline"); // Shape shader/pipeline (storage buffers, instanced) - shape_shader = sg_make_shader(&(sg_shader_desc) { + p->shape_shader = sg_make_shader(&(sg_shader_desc) { .vertex_func = { .source = (const char*) src_shaders_shape_wgsl, .entry = "vs_main", @@ -94,10 +104,12 @@ static void shape_init_pipeline(void) }, .label = "Shape shader", }); - panel_log(3, "[shapes] shader id=%d valid=%d", shape_shader.id, sg_isvalid()); + panel_log_debug(pl, "[shapes] shader id=%d valid=%d", p->shape_shader.id, sg_isvalid()); + if (p->shape_shader.id == SG_INVALID_ID) + panel_log(pl, 1, "[shapes] FAILED to create shape shader"); - shape_pipeline = sg_make_pipeline(&(sg_pipeline_desc) { - .shader = shape_shader, + p->shape_pipeline = sg_make_pipeline(&(sg_pipeline_desc) { + .shader = p->shape_shader, .index_type = SG_INDEXTYPE_NONE, .primitive_type = SG_PRIMITIVETYPE_LINE_STRIP, .layout.attrs = { @@ -105,15 +117,17 @@ static void shape_init_pipeline(void) }, .label = "Shape pipeline", }); - panel_log(3, "[shapes] pipeline id=%d valid=%d", shape_pipeline.id, sg_isvalid()); + panel_log_debug(pl, "[shapes] pipeline id=%d valid=%d", p->shape_pipeline.id, sg_isvalid()); + if (p->shape_pipeline.id == SG_INVALID_ID) + panel_log(pl, 1, "[shapes] FAILED to create shape pipeline"); } -static void shape_shutdown_pipeline(void) +static void shape_shutdown_pipeline(pipeline_ctx_t *p) { - sg_destroy_pipeline(shape_pipeline); - sg_destroy_shader(shape_shader); - sg_destroy_pipeline(overlay_pipeline); - sg_destroy_shader(overlay_shader); + sg_destroy_pipeline(p->shape_pipeline); + sg_destroy_shader(p->shape_shader); + sg_destroy_pipeline(p->overlay_pipeline); + sg_destroy_shader(p->overlay_shader); } #endif diff --git a/src/shape.h b/src/shape.h index fa59d5b..c3dd74b 100644 --- a/src/shape.h +++ b/src/shape.h @@ -3,16 +3,31 @@ #include "api.h" +#define PEN_SUBDIVISIONS 20 +// Bezier tessellation: target chord length in local-space units. +// Smaller = denser curves. Circle (r=1) with 0.10 gives ~17 subd/segment. +#define BEZIER_TARGET_CHORD 0.05f +#define BEZIER_MIN_SUBD 4 +#define BEZIER_MAX_SUBD 64 + typedef struct shape_vertex_t { float x, y; } shape_vertex_t; -typedef enum { - SHAPE_CIRCLE, - SHAPE_RECTANGLE, - SHAPE_STAR, - SHAPE_GENERIC, -} shape_kind_t; +// FNV-1a 64-bit hash over raw vertex bytes. Used to group shapes that share +// identical local-space geometry (e.g. all circles) while keeping freeform +// pen paths isolated in their own vertex buffers even when they happen to +// have the same vertex count. +static uint64_t hash_vertex_data(const shape_vertex_t *verts, uint32_t n) { + uint64_t h = 14695981039346656037ULL; + const uint8_t *data = (const uint8_t*)verts; + size_t len = (size_t)n * sizeof(shape_vertex_t); + for (size_t i = 0; i < len; i++) { + h ^= data[i]; + h *= 1099511628211ULL; + } + return h; +} typedef struct shape_uniform_t { mat4 transform; @@ -34,14 +49,30 @@ typedef struct shape_t { shape_uniform_t uniform; bool hovered; bool selected; + bool dirty; // true when GPU instance data needs re-upload float cx, cy; float sx, sy; float rotation; float cos_r, sin_r; - int kind; + float aabb_hx, aabb_hy; int group_id; + + // Bezier edit data (local space, NULL for shapes never edited) + shape_vertex_t *ctrl_points; + shape_vertex_t *ctrl_handle_in; + shape_vertex_t *ctrl_handle_out; + int ctrl_count; + bool closed; + char name[64]; + + // Grouping key for instanced rendering: shapes with the same + // (num_elements, vertex_hash) share a vertex buffer. Parametric shapes + // (circle, star, rect) naturally hash identically; freeform pen paths + // get unique hashes to prevent geometry cross-contamination. + uint64_t vertex_hash; + int group_index; // index into g_shape_groups[], -1 until pool rebuild } shape_t; // -- group entity (for nested groups) -- @@ -49,103 +80,202 @@ typedef struct shape_t { typedef struct { int id; int parent_id; // 0 = top-level group + int member_count; + int *member_indices; + bool collapsed; } group_t; -static group_t **g_group_by_id = NULL; -static int g_group_by_id_cap = 0; +// Group lookup index — maps group_id → group_t* for O(1) find_group(). +// Must be defined before the lookup functions that dereference its fields. +struct group_index_ctx_t { + group_t **by_id; + int cap; + bool dirty; +}; +// Full typedef for client use (userdata_t, etc.) +typedef struct group_index_ctx_t group_index_ctx_t; -static void group_index_rebuild(vector_t *groups) +// Group lookup functions now take group_index_ctx_t* so the index array is +// owned by the caller (embedded in userdata_t), not by a file-scope static. + +static void group_index_rebuild(group_index_ctx_t *gi, vector_t *groups) { int max_id = 0; for (int i = 0; i < groups->count; i++) { int gid = ((group_t*) vec_get(groups, i))->id; if (gid > max_id) max_id = gid; } - if (max_id >= g_group_by_id_cap) { - if (g_group_by_id) FREE(g_group_by_id); + if (max_id >= gi->cap) { + if (gi->by_id) FREE(gi->by_id); int new_cap = max_id + 64; - g_group_by_id = (group_t**) ALLOC((size_t)new_cap * sizeof(group_t*)); - memset(g_group_by_id, 0, (size_t)new_cap * sizeof(group_t*)); - g_group_by_id_cap = new_cap; + gi->by_id = (group_t**) ALLOC((size_t)new_cap * sizeof(group_t*)); + memset(gi->by_id, 0, (size_t)new_cap * sizeof(group_t*)); + gi->cap = new_cap; } else { - for (int i = 0; i <= max_id; i++) g_group_by_id[i] = NULL; + for (int i = 0; i <= max_id; i++) gi->by_id[i] = NULL; } for (int i = 0; i < groups->count; i++) { group_t *g = (group_t*) vec_get(groups, i); - g_group_by_id[g->id] = g; + gi->by_id[g->id] = g; } + gi->dirty = false; } -static void group_index_ensure_cap(int max_id) +static void group_index_ensure_cap(group_index_ctx_t *gi, int max_id) { - if (max_id >= g_group_by_id_cap) { + if (max_id >= gi->cap) { int new_cap = max_id + 64; - group_t **old = g_group_by_id; - g_group_by_id = (group_t**) ALLOC((size_t)new_cap * sizeof(group_t*)); + group_t **old = gi->by_id; + gi->by_id = (group_t**) ALLOC((size_t)new_cap * sizeof(group_t*)); if (old) { - memcpy(g_group_by_id, old, (size_t)g_group_by_id_cap * sizeof(group_t*)); + memcpy(gi->by_id, old, (size_t)gi->cap * sizeof(group_t*)); FREE(old); } - memset(g_group_by_id + g_group_by_id_cap, 0, - (size_t)(new_cap - g_group_by_id_cap) * sizeof(group_t*)); - g_group_by_id_cap = new_cap; + memset(gi->by_id + gi->cap, 0, + (size_t)(new_cap - gi->cap) * sizeof(group_t*)); + gi->cap = new_cap; } } -static group_t* find_group(vector_t *groups, int id) { - (void)groups; - if (id <= 0 || id >= g_group_by_id_cap) return NULL; - return g_group_by_id[id]; -} - -static int get_topmost_group(vector_t *groups, int gid) { - (void)groups; - while (gid != 0) { - group_t *g = find_group(groups, gid); - if (!g || g->parent_id == 0) return gid; - gid = g->parent_id; +static group_t* find_group(group_index_ctx_t *gi, vector_t *groups, int id) { + if (gi->dirty) { + group_index_rebuild(gi, groups); + gi->dirty = false; } - return 0; + if (id <= 0 || id >= gi->cap) return NULL; + return gi->by_id[id]; } -static bool is_shape_in_group_hierarchy(int shape_gid, int target_gid, vector_t *groups) { +static bool is_shape_in_group_hierarchy(group_index_ctx_t *gi, int shape_gid, int target_gid, vector_t *groups) { (void)groups; int cur = shape_gid; while (cur != 0) { if (cur == target_gid) return true; - group_t *g = find_group(groups, cur); + group_t *g = find_group(gi, groups, cur); if (!g) return false; cur = g->parent_id; } return false; } -static void group_index_shutdown(void) +static void group_index_add(group_index_ctx_t *gi, vector_t *groups, int id, int parent_id) { - if (g_group_by_id) FREE(g_group_by_id); - g_group_by_id = NULL; - g_group_by_id_cap = 0; + bool found = false; + for (int i = 0; i < groups->count; i++) { + if (((group_t*) vec_get(groups, i))->id == id) { found = true; break; } + } + if (!found) { + group_t g = { .id = id, .parent_id = parent_id, .collapsed = true }; + *((group_t*) vec_push(groups)) = g; + group_index_ensure_cap(gi, id); + if (id < gi->cap) gi->by_id[id] = (group_t*) vec_get(groups, groups->count - 1); + } } -// -- shared geometry buffers (one vbuf + one ibuf for all shapes) -- +static void group_index_remove(group_index_ctx_t *gi, vector_t *groups, int id) +{ + for (int i = 0; i < groups->count; i++) { + if (((group_t*) vec_get(groups, i))->id == id) { + group_t *grp = (group_t*) vec_get(groups, i); + if (grp->member_indices) FREE(grp->member_indices); + vec_remove_ordered(groups, i); + gi->dirty = true; + return; + } + } +} -static sg_buffer g_shape_data_sbuf = {0}; -static sg_buffer g_instance_map_sbuf = {0}; -static sg_view g_shape_data_view = {0}; -static sg_view g_instance_map_view = {0}; +static void group_index_shutdown(group_index_ctx_t *gi) +{ + if (gi->by_id) FREE(gi->by_id); + gi->by_id = NULL; + gi->cap = 0; + gi->dirty = false; +} -// Per-group vertex buffers: one per unique num_elements +// Rebuild member_indices for every group by scanning all shapes once. +// Call after any structural change (group, ungroup, delete, paste, undo, redo). +static void group_rebuild_members(group_index_ctx_t *gi, vector_t *groups, vector_t *shapes) +{ + if (gi->dirty) group_index_rebuild(gi, groups); + + // Free old member arrays and count members + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->member_indices) { FREE(grp->member_indices); grp->member_indices = NULL; } + grp->member_count = 0; + } + // Count shapes per group + for (int i = 0; i < shapes->count; i++) { + int gid = ((shape_t*) vec_get(shapes, i))->group_id; + if (gid == 0) continue; + if (gid < gi->cap) { + group_t *grp = gi->by_id[gid]; + if (grp) grp->member_count++; + } + } + // Allocate and fill + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->member_count > 0) { + grp->member_indices = (int*) ALLOC((size_t)grp->member_count * sizeof(int)); + grp->member_count = 0; // reset for fill pass + } + } + for (int i = 0; i < shapes->count; i++) { + shape_t *s = (shape_t*) vec_get(shapes, i); + if (s->group_id == 0) continue; + if (s->group_id < gi->cap) { + group_t *grp = gi->by_id[s->group_id]; + if (grp && grp->member_indices) + grp->member_indices[grp->member_count++] = i; + } + } +} + +// Free all member_indices. Call before freeing groups vector or resetting count. +static void group_shutdown_members(vector_t *groups) +{ + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->member_indices) { FREE(grp->member_indices); grp->member_indices = NULL; } + } +} + +// -- Context structs (owned by userdata_t) -- + +// Each unique (num_elements, vertex_hash) pair gets one vertex buffer shared +// by all shapes with identical local-space geometry (e.g. all circles). typedef struct { - uint32_t num_elements; - sg_buffer vbuf; + uint32_t num_elements; + uint64_t vertex_hash; + sg_buffer vbuf; } shape_group_buf_t; -static shape_group_buf_t *g_shape_groups = NULL; -static int g_shape_group_count = 0; -static bool g_shape_pool_dirty; -static bool g_shape_data_dirty; -static size_t g_shape_data_buf_size = 0; -static int g_instance_map_capacity = 0; +// GPU pool state for instanced shape rendering. +typedef struct { + sg_buffer data_sbuf; + sg_buffer instance_map_sbuf; + sg_view data_view; + sg_view instance_map_view; + + shape_group_buf_t *groups; + int group_count; + + shape_gpu_data_t *upload_buf; // CPU-side mirror of data_sbuf contents + int upload_buf_cap; + + size_t data_buf_size; + int instance_map_capacity; + + bool pool_dirty; + bool data_dirty; + bool states_dirty; + int frame_id; +} shape_pool_ctx_t; + +// -- shared geometry buffers (one vbuf per group + one instance-data sbuf) -- +// All state is in shape_pool_ctx_t, owned by the caller (userdata_t). static void shape_make_view_for_buffer(sg_view *view, sg_buffer buf) { @@ -155,158 +285,282 @@ static void shape_make_view_for_buffer(sg_view *view, sg_buffer buf) }); } -static shape_gpu_data_t *g_upload_buf = NULL; -static int g_upload_buf_cap = 0; - -static void shape_upload_data(vector_t *shapes) +// Upload shape instance data to GPU. Maintains a CPU-side mirror (upload_buf) +// so that only shapes marked dirty are rewritten; unchanged entries stay in +// the mirror from prior frames. Issues a single sg_update_buffer per frame +// to respect Sokol's per-buffer update limit. +static void shape_upload_data(shape_pool_ctx_t *sp, vector_t *shapes) { int n = shapes->count; - if (n == 0 || !g_shape_data_sbuf.id) return; + if (n == 0 || !sp->data_sbuf.id) return; size_t need = (size_t)n * sizeof(shape_gpu_data_t); - if (need > g_shape_data_buf_size) { - panel_log(2, "[shapes] upload_data: buffer too small (%zu < %zu), forcing rebuild", - g_shape_data_buf_size, need); - g_shape_pool_dirty = true; + if (need > sp->data_buf_size) { + sp->pool_dirty = true; return; } - if (n > g_upload_buf_cap) { - if (g_upload_buf) FREE(g_upload_buf); - g_upload_buf = (shape_gpu_data_t*) ALLOC(need); - g_upload_buf_cap = n; + // Resize CPU mirror when shape count grows (add/delete/paste) + if (n > sp->upload_buf_cap) { + if (sp->upload_buf) FREE(sp->upload_buf); + sp->upload_buf = (shape_gpu_data_t*) ALLOC(need); + sp->upload_buf_cap = n; + // Full mirror rebuild — all shapes are dirty on capacity change + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(shapes, i); + memcpy(sp->upload_buf[i].transform, s->uniform.transform, sizeof(mat4)); + sp->upload_buf[i].state = s->uniform.state; + memset(sp->upload_buf[i]._pad, 0, sizeof(sp->upload_buf[i]._pad)); + s->dirty = false; + } + sg_update_buffer(sp->data_sbuf, &(sg_range){sp->upload_buf, need}); + sp->data_dirty = false; + return; } + // Incremental: only write entries for shapes whose transform changed + bool any_dirty = false; for (int i = 0; i < n; i++) { shape_t *s = (shape_t*) vec_get(shapes, i); - memcpy(g_upload_buf[i].transform, s->uniform.transform, sizeof(mat4)); - g_upload_buf[i].state = s->uniform.state; - memset(g_upload_buf[i]._pad, 0, sizeof(g_upload_buf[i]._pad)); + if (!s->dirty) continue; + memcpy(sp->upload_buf[i].transform, s->uniform.transform, sizeof(mat4)); + sp->upload_buf[i].state = s->uniform.state; + memset(sp->upload_buf[i]._pad, 0, sizeof(sp->upload_buf[i]._pad)); + s->dirty = false; + any_dirty = true; } - sg_update_buffer(g_shape_data_sbuf, &(sg_range){g_upload_buf, need}); + + if (any_dirty) { + // Single sg_update_buffer — Sokol allows at most one per buffer per frame + sg_update_buffer(sp->data_sbuf, &(sg_range){sp->upload_buf, need}); + } + sp->data_dirty = false; } -static void shape_upload_instance_map(const uint32_t *map, int count) +static void shape_upload_instance_map(shape_pool_ctx_t *sp, const uint32_t *map, int count) { - if (count > g_instance_map_capacity) { - if (g_instance_map_sbuf.id) sg_destroy_buffer(g_instance_map_sbuf); - g_instance_map_sbuf = sg_make_buffer(&(sg_buffer_desc){ + if (count > sp->instance_map_capacity) { + if (sp->instance_map_sbuf.id) sg_destroy_buffer(sp->instance_map_sbuf); + sp->instance_map_sbuf = sg_make_buffer(&(sg_buffer_desc){ .size = (size_t)count * sizeof(uint32_t), .usage = { .storage_buffer = true, .stream_update = true }, .label = "Instance map", }); - g_instance_map_capacity = count; - shape_make_view_for_buffer(&g_instance_map_view, g_instance_map_sbuf); + sp->instance_map_capacity = count; + shape_make_view_for_buffer(&sp->instance_map_view, sp->instance_map_sbuf); } - sg_update_buffer(g_instance_map_sbuf, &(sg_range){map, (size_t)count * sizeof(uint32_t)}); - panel_log(3, "[shapes] upload_instance_map: count=%d buf=%d view=%d", - count, g_instance_map_sbuf.id, g_instance_map_view.id); + sg_update_buffer(sp->instance_map_sbuf, &(sg_range){map, (size_t)count * sizeof(uint32_t)}); } -static void shape_pool_rebuild(vector_t *shapes) +static void shape_pool_rebuild(shape_pool_ctx_t *sp, panel_log_ctx_t *pl, vector_t *shapes) { int n = shapes->count; - // Destroy old groups - for (int i = 0; i < g_shape_group_count; i++) { - if (g_shape_groups[i].vbuf.id) sg_destroy_buffer(g_shape_groups[i].vbuf); - } - FREE(g_shape_groups); - g_shape_groups = NULL; - g_shape_group_count = 0; - - if (g_shape_data_sbuf.id) { sg_destroy_buffer(g_shape_data_sbuf); g_shape_data_sbuf.id = 0; } - if (g_shape_data_view.id) { sg_destroy_view(g_shape_data_view); g_shape_data_view.id = 0; } - if (n == 0) { - g_shape_pool_dirty = false; + for (int i = 0; i < sp->group_count; i++) { + if (sp->groups[i].vbuf.id) sg_destroy_buffer(sp->groups[i].vbuf); + } + FREE(sp->groups); + sp->groups = NULL; + sp->group_count = 0; + if (sp->data_sbuf.id) { sg_destroy_buffer(sp->data_sbuf); sp->data_sbuf.id = 0; } + if (sp->data_view.id) { sg_destroy_view(sp->data_view); sp->data_view.id = 0; } + sp->data_buf_size = 0; + sp->pool_dirty = false; return; } - g_shape_data_buf_size = (size_t)n * sizeof(shape_gpu_data_t); - g_shape_data_sbuf = sg_make_buffer(&(sg_buffer_desc){ - .size = g_shape_data_buf_size, - .usage = { .storage_buffer = true, .stream_update = true }, - .label = "Shape data", - }); - shape_make_view_for_buffer(&g_shape_data_view, g_shape_data_sbuf); - // Data filled by shape_upload_data() in draw_shapes - - // Count unique num_elements - uint32_t max_ne = 0; - for (int i = 0; i < n; i++) { - uint32_t ne = ((shape_t*) vec_get(shapes, i))->num_elements; - if (ne > max_ne) max_ne = ne; + // Resize shape data buffer if needed (keep existing if size unchanged) + size_t need_data_size = (size_t)n * sizeof(shape_gpu_data_t); + if (need_data_size > sp->data_buf_size) { + if (sp->data_sbuf.id) { sg_destroy_buffer(sp->data_sbuf); sp->data_sbuf.id = 0; } + if (sp->data_view.id) { sg_destroy_view(sp->data_view); sp->data_view.id = 0; } + sp->data_buf_size = need_data_size; + sp->data_sbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = sp->data_buf_size, + .usage = { .storage_buffer = true, .stream_update = true }, + .label = "Shape data", + }); + if (sp->data_sbuf.id == SG_INVALID_ID) + panel_log(pl, 1, "[shapes] FAILED to create shape data buffer (%zu bytes)", sp->data_buf_size); + shape_make_view_for_buffer(&sp->data_view, sp->data_sbuf); } - int *ne_seen = (int*) ALLOC((size_t)(max_ne + 1) * sizeof(int)); - memset(ne_seen, 0, (size_t)(max_ne + 1) * sizeof(int)); + // Collect unique (num_elements, vertex_hash) pairs using a small + // open-addressing hash table to convert the O(n*n_keys) dedup scan + // into amortized O(n). n_keys is usually < 10 for procedural shapes. + typedef struct { uint32_t ne; uint64_t hash; } group_key_t; + group_key_t *keys = (group_key_t*) ALLOC((size_t)n * sizeof(group_key_t)); + int n_keys = 0; + enum { KEY_TABLE_SIZE = 64 }; + typedef struct { uint32_t ne; uint64_t hash; int idx; } key_entry_t; + key_entry_t key_table[KEY_TABLE_SIZE]; + memset(key_table, 0, sizeof(key_table)); + for (int i = 0; i < n; i++) { - uint32_t ne = ((shape_t*) vec_get(shapes, i))->num_elements; - ne_seen[ne] = 1; + shape_t *s = (shape_t*) vec_get(shapes, i); + uint32_t ne = s->num_elements; + uint64_t hash = s->vertex_hash; + uint32_t slot = (uint32_t)((ne * 0x9E3779B9) ^ (hash >> 32) ^ (hash & 0xFFFFFFFF)) + & (KEY_TABLE_SIZE - 1); + while (key_table[slot].ne != 0 || key_table[slot].hash != 0) { + if (key_table[slot].ne == ne && key_table[slot].hash == hash) break; + slot = (slot + 1) & (KEY_TABLE_SIZE - 1); + } + if (key_table[slot].ne == 0 && key_table[slot].hash == 0) { + key_table[slot].ne = ne; + key_table[slot].hash = hash; + key_table[slot].idx = n_keys; + keys[n_keys].ne = ne; + keys[n_keys].hash = hash; + n_keys++; + } } - int group_count = 0; - for (uint32_t ne = 0; ne <= max_ne; ne++) - if (ne_seen[ne]) group_count++; - // Create per-group vertex buffers (one copy of vertex data per unique num_elements) - g_shape_groups = (shape_group_buf_t*) ALLOC((size_t)group_count * sizeof(shape_group_buf_t)); - memset(g_shape_groups, 0, (size_t)group_count * sizeof(shape_group_buf_t)); + // Preserve existing group buffers whose (ne, hash) key still exists. + // Only new keys and orphaned keys cause allocation/deallocation. + shape_group_buf_t *new_groups = (shape_group_buf_t*) ALLOC((size_t)n_keys * sizeof(shape_group_buf_t)); + memset(new_groups, 0, (size_t)n_keys * sizeof(shape_group_buf_t)); - int gi = 0; - for (uint32_t ne = 0; ne <= max_ne; ne++) { - if (!ne_seen[ne]) continue; + bool *old_kept = (bool*) ALLOC((size_t)sp->group_count * sizeof(bool)); + memset(old_kept, 0, (size_t)sp->group_count * sizeof(bool)); - // Find first shape with this num_elements to use as vertex template - shape_t *ref = NULL; - for (int i = 0; i < n; i++) { - if (((shape_t*) vec_get(shapes, i))->num_elements == ne) { - ref = (shape_t*) vec_get(shapes, i); - break; + // Build a quick lookup from (ne, hash) -> old group index using the same + // fixed-size open-addressing scheme used for key dedup above. + int old_lookup_size = sp->group_count > 0 ? sp->group_count * 2 + 16 : 16; + if (old_lookup_size > 512) old_lookup_size = 512; + int *old_lookup_keys = NULL; + int *old_lookup_vals = NULL; + if (sp->group_count > 0) { + old_lookup_keys = (int*) ALLOC((size_t)old_lookup_size * 2 * sizeof(int)); + old_lookup_vals = old_lookup_keys + old_lookup_size; + for (int i = 0; i < old_lookup_size; i++) { + old_lookup_keys[i] = -1; + old_lookup_vals[i] = -1; + } + for (int old_i = 0; old_i < sp->group_count; old_i++) { + uint32_t ne = sp->groups[old_i].num_elements; + uint64_t hash = sp->groups[old_i].vertex_hash; + uint32_t slot = (uint32_t)((ne * 0x9E3779B9) ^ (hash >> 32) ^ (hash & 0xFFFFFFFF)) + & (uint32_t)(old_lookup_size - 1); + while (old_lookup_keys[slot] != -1) + slot = (slot + 1) & (uint32_t)(old_lookup_size - 1); + int packed = (int)(ne ^ (uint32_t)(hash ^ (hash >> 32))); + old_lookup_keys[slot] = packed; + old_lookup_vals[slot] = old_i; + } + } + + for (int k = 0; k < n_keys; k++) { + bool reused = false; + if (sp->group_count > 0) { + uint32_t ne = keys[k].ne; + uint64_t hash = keys[k].hash; + int search_packed = (int)(ne ^ (uint32_t)(hash ^ (hash >> 32))); + uint32_t slot = (uint32_t)((ne * 0x9E3779B9) ^ (hash >> 32) ^ (hash & 0xFFFFFFFF)) + & (uint32_t)(old_lookup_size - 1); + while (old_lookup_keys[slot] != -1) { + if (old_lookup_keys[slot] == search_packed) { + int old_i = old_lookup_vals[slot]; + if (sp->groups[old_i].num_elements == ne && + sp->groups[old_i].vertex_hash == hash && + sp->groups[old_i].vbuf.id) { + new_groups[k] = sp->groups[old_i]; + old_kept[old_i] = true; + reused = true; + break; + } + } + slot = (slot + 1) & (uint32_t)(old_lookup_size - 1); } } - g_shape_groups[gi].num_elements = ne; - g_shape_groups[gi].vbuf = sg_make_buffer(&(sg_buffer_desc){ - .data = { ref->verts, (size_t)ne * sizeof(shape_vertex_t) }, - .label = "Shape group verts", - }); - gi++; - } - g_shape_group_count = group_count; - - FREE(ne_seen); - - panel_log(3, "[shapes] pool_rebuild: %d shapes, %d groups, data_buf=%d data_view=%d", - n, group_count, g_shape_data_sbuf.id, g_shape_data_view.id); - for (int gi = 0; gi < group_count; gi++) { - panel_log(3, "[shapes] group[%d]: ne=%u vbuf=%d", - gi, g_shape_groups[gi].num_elements, g_shape_groups[gi].vbuf.id); + if (!reused) { + shape_t *ref = NULL; + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(shapes, i); + if (s->num_elements == keys[k].ne && s->vertex_hash == keys[k].hash) { + ref = s; + break; + } + } + new_groups[k].num_elements = keys[k].ne; + new_groups[k].vertex_hash = keys[k].hash; + new_groups[k].vbuf = sg_make_buffer(&(sg_buffer_desc){ + .size = (size_t)keys[k].ne * sizeof(shape_vertex_t), + .usage = { .stream_update = true }, + .label = "Shape group verts", + }); + sg_update_buffer(new_groups[k].vbuf, &(sg_range){ + ref->verts, (size_t)keys[k].ne * sizeof(shape_vertex_t) + }); + } } - g_shape_pool_dirty = false; + if (old_lookup_keys) FREE(old_lookup_keys); + + for (int old_i = 0; old_i < sp->group_count; old_i++) { + if (!old_kept[old_i] && sp->groups[old_i].vbuf.id) + sg_destroy_buffer(sp->groups[old_i].vbuf); + } + FREE(old_kept); + FREE(sp->groups); + sp->groups = new_groups; + sp->group_count = n_keys; + + // Assign group_index to shapes that were created since the last rebuild. + // Pre-existing shapes keep their cached group_index (validated above). + for (int i = 0; i < n; i++) { + shape_t *s = (shape_t*) vec_get(shapes, i); + if (s->group_index >= 0 && s->group_index < n_keys) continue; + s->group_index = -1; + for (int k = 0; k < n_keys; k++) { + if (sp->groups[k].num_elements == s->num_elements && + sp->groups[k].vertex_hash == s->vertex_hash) { + s->group_index = k; + break; + } + } + } + + FREE(keys); + + if (pl) { + panel_log_debug(pl, "[shapes] pool_rebuild: %d shapes, %d groups, data_buf=%d data_view=%d", + n, n_keys, sp->data_sbuf.id, sp->data_view.id); + for (int gi = 0; gi < n_keys; gi++) { + panel_log_debug(pl, "[shapes] group[%d]: ne=%u hash=%llx vbuf=%d", + gi, sp->groups[gi].num_elements, + (unsigned long long)sp->groups[gi].vertex_hash, + sp->groups[gi].vbuf.id); + } + } + + sp->pool_dirty = false; } -static void shape_pool_shutdown(void) +static void shape_pool_shutdown(shape_pool_ctx_t *sp) { - for (int i = 0; i < g_shape_group_count; i++) { - if (g_shape_groups[i].vbuf.id) sg_destroy_buffer(g_shape_groups[i].vbuf); + for (int i = 0; i < sp->group_count; i++) { + if (sp->groups[i].vbuf.id) sg_destroy_buffer(sp->groups[i].vbuf); } - FREE(g_shape_groups); - g_shape_groups = NULL; - g_shape_group_count = 0; + FREE(sp->groups); + sp->groups = NULL; + sp->group_count = 0; - if (g_shape_data_view.id) { sg_destroy_view(g_shape_data_view); g_shape_data_view.id = 0; } - if (g_instance_map_view.id) { sg_destroy_view(g_instance_map_view); g_instance_map_view.id = 0; } - if (g_shape_data_sbuf.id) { sg_destroy_buffer(g_shape_data_sbuf); g_shape_data_sbuf.id = 0; } - if (g_instance_map_sbuf.id) { sg_destroy_buffer(g_instance_map_sbuf); g_instance_map_sbuf.id = 0; } - g_instance_map_capacity = 0; - if (g_upload_buf) { FREE(g_upload_buf); g_upload_buf = NULL; } - g_upload_buf_cap = 0; + if (sp->data_view.id) { sg_destroy_view(sp->data_view); sp->data_view.id = 0; } + if (sp->instance_map_view.id) { sg_destroy_view(sp->instance_map_view); sp->instance_map_view.id = 0; } + if (sp->data_sbuf.id) { sg_destroy_buffer(sp->data_sbuf); sp->data_sbuf.id = 0; } + if (sp->instance_map_sbuf.id) { sg_destroy_buffer(sp->instance_map_sbuf); sp->instance_map_sbuf.id = 0; } + sp->instance_map_capacity = 0; + if (sp->upload_buf) { FREE(sp->upload_buf); sp->upload_buf = NULL; } + sp->upload_buf_cap = 0; } #define SHAPE_HOVER_PX 6.0f +static void shape_regenerate_from_ctrl(shape_pool_ctx_t *sp, shape_t *s); + static int shape_calc_segments(float r) { int n = (int)(fabsf(r) * 0.5f) + 16; @@ -319,12 +573,62 @@ static void shape_init_common(shape_t *s) { s->hovered = false; s->selected = false; + s->dirty = true; // first upload always needed s->uniform.state = 0; memset(s->uniform._pad, 0, sizeof(s->uniform._pad)); s->group_id = 0; + s->aabb_hx = 0; + s->aabb_hy = 0; + s->ctrl_points = NULL; + s->ctrl_handle_in = NULL; + s->ctrl_handle_out = NULL; + s->ctrl_count = 0; + s->closed = false; + s->name[0] = '\0'; + s->vertex_hash = 0; + s->group_index = -1; } -static void shape_build_transform(shape_t *s) +// Recompute AABB from actual local-space vertex positions rather than +// assuming [-1,1] bounds. Needed because Bezier edits can push vertices +// outside the unit square. Also re-centers local-space vertices around +// origin so cx/cy tracks the true geometry center. +static void shape_update_aabb(shape_t *s) { + if (s->num_verts == 0) return; + float lmin_x = s->verts[0].x, lmax_x = s->verts[0].x; + float lmin_y = s->verts[0].y, lmax_y = s->verts[0].y; + for (uint32_t i = 1; i < s->num_verts; i++) { + float vx = s->verts[i].x, vy = s->verts[i].y; + if (vx < lmin_x) lmin_x = vx; + if (vx > lmax_x) lmax_x = vx; + if (vy < lmin_y) lmin_y = vy; + if (vy > lmax_y) lmax_y = vy; + } + float lcx = (lmin_x + lmax_x) * 0.5f; + float lcy = (lmin_y + lmax_y) * 0.5f; + float lhx = (lmax_x - lmin_x) * 0.5f; + float lhy = (lmax_y - lmin_y) * 0.5f; + float sc = s->cos_r, ss = s->sin_r; + s->cx += lcx * s->sx * sc - lcy * s->sy * ss; + s->cy += lcx * s->sx * ss + lcy * s->sy * sc; + for (uint32_t i = 0; i < s->num_verts; i++) { + s->verts[i].x -= lcx; + s->verts[i].y -= lcy; + } + for (int i = 0; i < s->ctrl_count; i++) { + s->ctrl_points[i].x -= lcx; + s->ctrl_points[i].y -= lcy; + s->ctrl_handle_in[i].x -= lcx; + s->ctrl_handle_in[i].y -= lcy; + s->ctrl_handle_out[i].x -= lcx; + s->ctrl_handle_out[i].y -= lcy; + } + s->vertex_hash = hash_vertex_data(s->verts, s->num_elements); + s->aabb_hx = fabsf(sc) * fabsf(s->sx) * lhx + fabsf(ss) * fabsf(s->sy) * lhy; + s->aabb_hy = fabsf(ss) * fabsf(s->sx) * lhx + fabsf(sc) * fabsf(s->sy) * lhy; +} + +static void shape_build_transform(shape_pool_ctx_t *sp, shape_t *s) { mat4 T, R, S, RS; glm_translate_make(T, (vec3){s->cx, s->cy, 0.0f}); @@ -334,40 +638,57 @@ static void shape_build_transform(shape_t *s) glm_mat4_mul(T, RS, s->uniform.transform); s->cos_r = R[0][0]; s->sin_r = R[0][1]; - g_shape_data_dirty = true; + s->aabb_hx = fabsf(s->cos_r) * fabsf(s->sx) + fabsf(s->sin_r) * fabsf(s->sy); + s->aabb_hy = fabsf(s->sin_r) * fabsf(s->sx) + fabsf(s->cos_r) * fabsf(s->sy); + s->dirty = true; + sp->data_dirty = true; } -static void shape_retranslate(shape_t *s) +static void shape_retranslate(shape_pool_ctx_t *sp, shape_t *s) { s->uniform.transform[3][0] = s->cx; s->uniform.transform[3][1] = s->cy; - g_shape_data_dirty = true; + s->dirty = true; + sp->data_dirty = true; } -static void shape_make_buffers(shape_t *s) +// Cache group_index at shape creation time. Only triggers a pool rebuild when +// the (num_elements, vertex_hash) key is genuinely new — the common case +// (e.g. creating another circle) reuses an existing vertex buffer. +static void shape_make_buffers(shape_pool_ctx_t *sp, shape_t *s) { - for (int i = 0; i < g_shape_group_count; i++) { - if (g_shape_groups[i].num_elements == s->num_elements) return; + for (int i = 0; i < sp->group_count; i++) { + if (sp->groups[i].num_elements == s->num_elements && + sp->groups[i].vertex_hash == s->vertex_hash) { + s->group_index = i; + return; + } } - g_shape_pool_dirty = true; + s->group_index = -1; + sp->pool_dirty = true; } -static void shape_shutdown(shape_t *s) +static void shape_shutdown(shape_pool_ctx_t *sp, shape_t *s) { - g_shape_pool_dirty = true; + sp->pool_dirty = true; FREE(s->verts); FREE(s->indices); + FREE(s->ctrl_points); + FREE(s->ctrl_handle_in); + FREE(s->ctrl_handle_out); + s->ctrl_count = 0; } -static void shape_regenerate(shape_t *s) +static void shape_regenerate(shape_pool_ctx_t *sp, shape_t *s) { - shape_build_transform(s); + shape_build_transform(sp, s); + shape_update_aabb(s); } -static void shape_set_state(shape_t *s, bool hovered, bool selected) +static void shape_set_state(shape_pool_ctx_t *sp, shape_t *s, bool hovered, bool selected) { uint32_t new_state = selected ? 2u : (hovered ? 1u : 0u); - if (s->uniform.state != new_state) g_shape_data_dirty = true; + if (s->uniform.state != new_state) { s->dirty = true; sp->data_dirty = true; } s->hovered = hovered; s->selected = selected; s->uniform.state = new_state; @@ -391,9 +712,13 @@ static bool shape_hit_test(shape_t *s, float wx, float wy, float world_tol) 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; + + // Per-axis inverse tolerance: a world-space circle of radius world_tol + // maps to a local-space axis-aligned ellipse with semi-axes + // world_tol/|sx| and world_tol/|sy|. The rotation terms cancel out + // because the coordinate transform already handles them. + float ix = fabsf(s->sx) / world_tol; + float iy = fabsf(s->sy) / world_tol; if (point_in_polygon(lx, ly, s->verts, s->num_verts)) return true; @@ -407,95 +732,283 @@ static bool shape_hit_test(shape_t *s, float wx, float wy, float world_tol) 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 ddx = lx - cx, ddy = ly - cy; - if (ddx * ddx + ddy * ddy <= tol_sq) return true; + float ddx = (lx - cx) * ix, ddy = (ly - cy) * iy; + if (ddx * ddx + ddy * ddy <= 1.0f) return true; } return false; } -static shape_t shape_circle(float x, float y, float r) +static shape_t shape_circle(shape_pool_ctx_t *sp, float x, float y, float r) { + // 4-point Bezier circle approximation: magic constant k = (4/3)*tan(pi/8) + const float k = 0.5522847498f; + shape_t s; + memset(&s, 0, sizeof(s)); s.cx = x; s.cy = y; s.sx = r; s.sy = r; s.rotation = 0.0f; - s.kind = SHAPE_CIRCLE; - - int segs = shape_calc_segments(r); - int count = segs + 1; - 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.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; - s.num_elements = (uint32_t)count; - s.num_verts = (uint32_t)segs; - shape_init_common(&s); - shape_build_transform(&s); - shape_make_buffers(&s); + + s.ctrl_count = 4; + s.closed = true; + s.ctrl_points = (shape_vertex_t*) ALLOC(4 * sizeof(shape_vertex_t)); + s.ctrl_handle_in = (shape_vertex_t*) ALLOC(4 * sizeof(shape_vertex_t)); + s.ctrl_handle_out = (shape_vertex_t*) ALLOC(4 * sizeof(shape_vertex_t)); + + // Anchors: top, right, bottom, left (clockwise from top) + s.ctrl_points[0] = (shape_vertex_t){ 0.0f, 1.0f}; + s.ctrl_points[1] = (shape_vertex_t){ 1.0f, 0.0f}; + s.ctrl_points[2] = (shape_vertex_t){ 0.0f, -1.0f}; + s.ctrl_points[3] = (shape_vertex_t){-1.0f, 0.0f}; + + // Handles at k distance along tangent (perpendicular to radius) + s.ctrl_handle_in[0] = (shape_vertex_t){ -k, 1.0f}; + s.ctrl_handle_out[0] = (shape_vertex_t){ k, 1.0f}; + s.ctrl_handle_in[1] = (shape_vertex_t){ 1.0f, k}; + s.ctrl_handle_out[1] = (shape_vertex_t){ 1.0f, -k}; + s.ctrl_handle_in[2] = (shape_vertex_t){ k, -1.0f}; + s.ctrl_handle_out[2] = (shape_vertex_t){ -k, -1.0f}; + s.ctrl_handle_in[3] = (shape_vertex_t){-1.0f, -k}; + s.ctrl_handle_out[3] = (shape_vertex_t){-1.0f, k}; + + strncpy(s.name, "Circle", sizeof(s.name) - 1); + shape_regenerate_from_ctrl(sp, &s); return s; } -static shape_t shape_star(float x, float y, float outer_r, float inner_r, - int points) -{ - shape_t s; - s.cx = x; s.cy = y; - s.sx = outer_r; s.sy = outer_r; - s.rotation = 0.0f; - s.kind = SHAPE_STAR; - - int n = points * 2; - int count = n + 1; - s.verts = (shape_vertex_t*) ALLOC(count * sizeof(shape_vertex_t)); - s.indices = (uint16_t*) ALLOC(count * sizeof(uint16_t)); - - float inner_ratio = inner_r / outer_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_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; - s.num_elements = (uint32_t)count; - s.num_verts = (uint32_t)n; - - shape_init_common(&s); - shape_build_transform(&s); - shape_make_buffers(&s); - return s; -} - -static shape_t shape_rectangle(float x, float y, float w, float h) +static shape_t shape_rectangle(shape_pool_ctx_t *sp, float x, float y, float w, float h) { shape_t s; + memset(&s, 0, sizeof(s)); s.cx = x; s.cy = y; s.sx = w * 0.5f; s.sy = h * 0.5f; s.rotation = 0.0f; - s.kind = SHAPE_RECTANGLE; - - s.num_verts = 4; - s.num_elements = 5; - s.verts = (shape_vertex_t*) ALLOC(5 * sizeof(shape_vertex_t)); - s.indices = (uint16_t*) ALLOC(5 * sizeof(uint16_t)); - - s.verts[0] = (shape_vertex_t){-1.0f, -1.0f}; - s.verts[1] = (shape_vertex_t){ 1.0f, -1.0f}; - s.verts[2] = (shape_vertex_t){ 1.0f, 1.0f}; - s.verts[3] = (shape_vertex_t){-1.0f, 1.0f}; - s.verts[4] = s.verts[0]; - for (int i = 0; i < 5; i++) s.indices[i] = (uint16_t)i; - shape_init_common(&s); - shape_build_transform(&s); - shape_make_buffers(&s); + + s.ctrl_count = 4; + s.closed = true; + s.ctrl_points = (shape_vertex_t*) ALLOC(4 * sizeof(shape_vertex_t)); + s.ctrl_handle_in = (shape_vertex_t*) ALLOC(4 * sizeof(shape_vertex_t)); + s.ctrl_handle_out = (shape_vertex_t*) ALLOC(4 * sizeof(shape_vertex_t)); + + s.ctrl_points[0] = (shape_vertex_t){-1.0f, -1.0f}; + s.ctrl_points[1] = (shape_vertex_t){ 1.0f, -1.0f}; + s.ctrl_points[2] = (shape_vertex_t){ 1.0f, 1.0f}; + s.ctrl_points[3] = (shape_vertex_t){-1.0f, 1.0f}; + + s.ctrl_handle_in[0] = (shape_vertex_t){-1.0f, -1.0f/3.0f}; + s.ctrl_handle_out[0] = (shape_vertex_t){-1.0f/3.0f, -1.0f}; + s.ctrl_handle_in[1] = (shape_vertex_t){ 1.0f/3.0f, -1.0f}; + s.ctrl_handle_out[1] = (shape_vertex_t){ 1.0f, -1.0f/3.0f}; + s.ctrl_handle_in[2] = (shape_vertex_t){ 1.0f, 1.0f/3.0f}; + s.ctrl_handle_out[2] = (shape_vertex_t){ 1.0f/3.0f, 1.0f}; + s.ctrl_handle_in[3] = (shape_vertex_t){-1.0f/3.0f, 1.0f}; + s.ctrl_handle_out[3] = (shape_vertex_t){-1.0f, 1.0f/3.0f}; + + strncpy(s.name, "Rectangle", sizeof(s.name) - 1); + shape_regenerate_from_ctrl(sp, &s); return s; } +// -- Catmull-Rom spline for pen tool -- + +static shape_vertex_t catmull_rom_eval(float t, shape_vertex_t p0, shape_vertex_t p1, + shape_vertex_t p2, shape_vertex_t p3) +{ + float t2 = t * t; + float t3 = t2 * t; + return (shape_vertex_t){ + 0.5f * ((2.0f * p1.x) + + (-p0.x + p2.x) * t + + (2.0f * p0.x - 5.0f * p1.x + 4.0f * p2.x - p3.x) * t2 + + (-p0.x + 3.0f * p1.x - 3.0f * p2.x + p3.x) * t3), + 0.5f * ((2.0f * p1.y) + + (-p0.y + p2.y) * t + + (2.0f * p0.y - 5.0f * p1.y + 4.0f * p2.y - p3.y) * t2 + + (-p0.y + 3.0f * p1.y - 3.0f * p2.y + p3.y) * t3) + }; +} + +// Build a smooth line strip from control points using Catmull-Rom interpolation. +// Returns the number of output vertices written to `out`. +static int pen_generate_curve(const shape_vertex_t *ctrl, int ctrl_count, + shape_vertex_t *out, int out_cap, int subdivisions) +{ + if (ctrl_count < 2 || out_cap < 2) return 0; + int max_out = (ctrl_count - 1) * subdivisions + 1; + if (max_out > out_cap) max_out = out_cap; + + // Phantom endpoints: duplicate first and last control points + for (int seg = 0; seg < ctrl_count - 1 && (seg * subdivisions) < max_out; seg++) { + shape_vertex_t p0 = ctrl[seg > 0 ? seg - 1 : 0]; + shape_vertex_t p1 = ctrl[seg]; + shape_vertex_t p2 = ctrl[seg + 1]; + shape_vertex_t p3 = ctrl[seg + 2 < ctrl_count ? seg + 2 : ctrl_count - 1]; + + int steps = subdivisions; + if (seg == ctrl_count - 2) steps = max_out - seg * subdivisions; + for (int s = 0; s < steps; s++) { + float t = (float)s / (float)steps; + out[seg * subdivisions + s] = catmull_rom_eval(t, p0, p1, p2, p3); + } + } + // Ensure last point is exactly the final control point + out[max_out - 1] = ctrl[ctrl_count - 1]; + return max_out; +} + +// Create a shape from world-space line-strip vertices. +// Vertices are converted to local space (centered, normalized to AABB). +static shape_t shape_from_world_verts(shape_pool_ctx_t *sp, const shape_vertex_t *wverts, int wcount) +{ + shape_t s; + memset(&s, 0, sizeof(s)); + s.rotation = 0.0f; + + // Compute world-space AABB + float min_x = wverts[0].x, min_y = wverts[0].y; + float max_x = min_x, max_y = min_y; + for (int i = 1; i < wcount; i++) { + if (wverts[i].x < min_x) min_x = wverts[i].x; + if (wverts[i].y < min_y) min_y = wverts[i].y; + if (wverts[i].x > max_x) max_x = wverts[i].x; + if (wverts[i].y > max_y) max_y = wverts[i].y; + } + + float hx = (max_x - min_x) * 0.5f; + float hy = (max_y - min_y) * 0.5f; + s.cx = (min_x + max_x) * 0.5f; + s.cy = (min_y + max_y) * 0.5f; + s.sx = hx > 0.0001f ? hx : 1.0f; + s.sy = hy > 0.0001f ? hy : 1.0f; + + s.num_verts = (uint32_t)wcount; + s.num_elements = (uint32_t)wcount; + s.verts = (shape_vertex_t*) ALLOC((size_t)wcount * sizeof(shape_vertex_t)); + s.indices = (uint16_t*) ALLOC((size_t)wcount * sizeof(uint16_t)); + + for (int i = 0; i < wcount; i++) { + s.verts[i].x = (wverts[i].x - s.cx) / s.sx; + s.verts[i].y = (wverts[i].y - s.cy) / s.sy; + s.indices[i] = (uint16_t)i; + } + + shape_init_common(&s); + s.vertex_hash = hash_vertex_data(s.verts, s.num_elements); + strncpy(s.name, "Path", sizeof(s.name) - 1); + shape_build_transform(sp, &s); + shape_update_aabb(&s); + shape_make_buffers(sp, &s); + return s; +} + +// -- Coordinate helpers for edit mode -- + +static shape_vertex_t local_to_world(const shape_t *s, float lx, float ly) +{ + return (shape_vertex_t){ + s->cx + lx * s->sx * s->cos_r - ly * s->sy * s->sin_r, + s->cy + lx * s->sx * s->sin_r + ly * s->sy * s->cos_r + }; +} + +static shape_vertex_t world_to_local(const shape_t *s, float wx, float wy) +{ + float dx = wx - s->cx, dy = wy - s->cy; + return (shape_vertex_t){ + (dx * s->cos_r + dy * s->sin_r) / s->sx, + (-dx * s->sin_r + dy * s->cos_r) / s->sy + }; +} + +// -- Cubic Bezier evaluation for edit mode -- + +static shape_vertex_t bezier_eval_segment(float t, + shape_vertex_t p0, shape_vertex_t p1, + shape_vertex_t p2, shape_vertex_t p3) +{ + float u = 1.0f - t; + float u2 = u * u, u3 = u2 * u; + float t2 = t * t, t3 = t2 * t; + return (shape_vertex_t){ + u3 * p0.x + 3.0f * u2 * t * p1.x + 3.0f * u * t2 * p2.x + t3 * p3.x, + u3 * p0.y + 3.0f * u2 * t * p1.y + 3.0f * u * t2 * p2.y + t3 * p3.y + }; +} + +// Approximate arc length of a bezier segment via its control polygon. +static float bezier_control_polygon_len(shape_vertex_t p0, shape_vertex_t p1, + shape_vertex_t p2, shape_vertex_t p3) { + float dx = p1.x - p0.x, dy = p1.y - p0.y; + float len = sqrtf(dx*dx + dy*dy); + dx = p2.x - p1.x; dy = p2.y - p1.y; + len += sqrtf(dx*dx + dy*dy); + dx = p3.x - p2.x; dy = p3.y - p2.y; + len += sqrtf(dx*dx + dy*dy); + return len; +} + +// Regenerate verts/indices from ctrl_points + Bezier handles. +// Subdivisions per segment are derived from the control-polygon arc length, +// giving consistent smoothness regardless of shape type or size. +static void shape_regenerate_from_ctrl(shape_pool_ctx_t *sp, shape_t *s) +{ + int n = s->ctrl_count; + if (n < 2) return; + + FREE(s->verts); + FREE(s->indices); + + int segs = s->closed ? n : n - 1; + + // First pass: compute subdivisions per segment, sum total vertex count + int *seg_subd = (int*) ALLOC((size_t)segs * sizeof(int)); + int total_verts = 1; // +1 for the closing anchor duplicate + for (int seg = 0; seg < segs; seg++) { + int i0 = seg; + int i1 = s->closed ? ((seg + 1) % n) : seg + 1; + float len = bezier_control_polygon_len( + s->ctrl_points[i0], s->ctrl_handle_out[i0], + s->ctrl_handle_in[i1], s->ctrl_points[i1]); + int subd = (int)(len / BEZIER_TARGET_CHORD); + if (subd < BEZIER_MIN_SUBD) subd = BEZIER_MIN_SUBD; + if (subd > BEZIER_MAX_SUBD) subd = BEZIER_MAX_SUBD; + seg_subd[seg] = subd; + total_verts += subd; + } + + s->num_verts = s->closed ? (uint32_t)(total_verts - 1) : (uint32_t)total_verts; + s->num_elements = (uint32_t)total_verts; + s->verts = (shape_vertex_t*) ALLOC((size_t)total_verts * sizeof(shape_vertex_t)); + s->indices = (uint16_t*) ALLOC((size_t)total_verts * sizeof(uint16_t)); + + // Second pass: generate vertices + int vi = 0; + for (int seg = 0; seg < segs; seg++) { + int i0 = seg; + int i1 = s->closed ? ((seg + 1) % n) : seg + 1; + shape_vertex_t p0 = s->ctrl_points[i0]; + shape_vertex_t p1 = s->ctrl_handle_out[i0]; + shape_vertex_t p2 = s->ctrl_handle_in[i1]; + shape_vertex_t p3 = s->ctrl_points[i1]; + + int steps = seg_subd[seg]; + for (int k = 0; k < steps; k++) { + float t = (float)k / (float)steps; + s->verts[vi++] = bezier_eval_segment(t, p0, p1, p2, p3); + } + } + s->verts[vi++] = s->ctrl_points[0]; + + for (int i = 0; i < total_verts; i++) s->indices[i] = (uint16_t)i; + + FREE(seg_subd); + + s->vertex_hash = hash_vertex_data(s->verts, s->num_elements); + shape_build_transform(sp, s); + shape_update_aabb(s); + sp->pool_dirty = true; +} + #endif diff --git a/src/spatial.h b/src/spatial.h index 51bc4e9..2592982 100644 --- a/src/spatial.h +++ b/src/spatial.h @@ -3,11 +3,10 @@ #include "api.h" -// Tunable constants #define SPATIAL_CELL_SIZE 250.0f -#define SPATIAL_HASH_BITS 8 +#define SPATIAL_HASH_BITS 16 #define SPATIAL_HASH_SIZE (1 << SPATIAL_HASH_BITS) -#define SPATIAL_QUERY_RANGE 1 +#define SPATIAL_MAX_CELLS_PER_SHAPE 64 typedef struct { int shape_idx; @@ -24,6 +23,9 @@ typedef struct { typedef struct { spatial_slot_t slots[SPATIAL_HASH_SIZE]; + int *visited; // per-shape query-dedup frame tags + int visited_cap; + int query_frame; // increments per query, never 0 bool dirty; } spatial_grid_t; @@ -32,23 +34,11 @@ 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 = s->cos_r; - float sin_r = s->sin_r; - 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; + grid->query_frame = 1; } static void spatial_mark_dirty(spatial_grid_t *grid) @@ -61,9 +51,58 @@ 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); } + if (grid->visited) FREE(grid->visited); memset(grid, 0, sizeof(*grid)); } +// Find or create the slot for cell (cx, cy). Returns the slot index. +static int spatial_find_slot(spatial_grid_t *grid, int cx, int cy) +{ + int idx = spatial_hash(cx, cy) & (SPATIAL_HASH_SIZE - 1); + int probe = 0; + while (grid->slots[idx].occupied && probe < SPATIAL_HASH_SIZE) { + if (grid->slots[idx].cx == cx && grid->slots[idx].cy == cy) break; + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + probe++; + } + if (!grid->slots[idx].occupied) { + grid->slots[idx].occupied = true; + grid->slots[idx].cx = cx; + grid->slots[idx].cy = cy; + } + return idx; +} + +// Enumerate the cells a shape's AABB overlaps. Returns the number of cells. +// Capped at SPATIAL_MAX_CELLS_PER_SHAPE; falls back to the center cell for +// degenerate huge shapes to avoid hash-table explosion. +static int spatial_shape_cells(shape_t *s, int *cell_xs, int *cell_ys) +{ + int cmin_x = (int)floorf((s->cx - s->aabb_hx) / SPATIAL_CELL_SIZE); + int cmax_x = (int)floorf((s->cx + s->aabb_hx) / SPATIAL_CELL_SIZE); + int cmin_y = (int)floorf((s->cy - s->aabb_hy) / SPATIAL_CELL_SIZE); + int cmax_y = (int)floorf((s->cy + s->aabb_hy) / SPATIAL_CELL_SIZE); + + int ncx = cmax_x - cmin_x + 1; + int ncy = cmax_y - cmin_y + 1; + + if (ncx <= 0 || ncy <= 0 || ncx * ncy > SPATIAL_MAX_CELLS_PER_SHAPE) { + cell_xs[0] = (int)floorf(s->cx / SPATIAL_CELL_SIZE); + cell_ys[0] = (int)floorf(s->cy / SPATIAL_CELL_SIZE); + return 1; + } + + int count = 0; + for (int cy = cmin_y; cy <= cmax_y; cy++) { + for (int cx = cmin_x; cx <= cmax_x; cx++) { + cell_xs[count] = cx; + cell_ys[count] = cy; + count++; + } + } + return count; +} + static void spatial_rebuild(spatial_grid_t *grid, vector_t *shapes) { if (!grid->dirty) return; @@ -79,142 +118,224 @@ static void spatial_rebuild(spatial_grid_t *grid, vector_t *shapes) if (n == 0) return; + // Grow visited array if needed (used for query-result dedup) + if (n > grid->visited_cap) { + if (grid->visited) FREE(grid->visited); + grid->visited = (int*)ALLOC((size_t)n * sizeof(int)); + grid->visited_cap = n; + } + memset(grid->visited, 0, (size_t)n * sizeof(int)); + grid->query_frame = 1; + + int cell_xs[128], cell_ys[128]; + // 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); - int probe = 0; - while (grid->slots[idx].occupied && probe < SPATIAL_HASH_SIZE) { - if (grid->slots[idx].cx == ccx && grid->slots[idx].cy == ccy) break; - idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); - probe++; + shape_t *s = (shape_t*)vec_get(shapes, i); + int nc = spatial_shape_cells(s, cell_xs, cell_ys); + for (int c = 0; c < nc; c++) { + int idx = spatial_find_slot(grid, cell_xs[c], cell_ys[c]); + grid->slots[idx].count++; } - - 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 + // Phase 2: resize entry arrays when needed for (int i = 0; i < SPATIAL_HASH_SIZE; i++) { if (!grid->slots[i].occupied) continue; - if (grid->slots[i].count > grid->slots[i].capacity) { + int need = grid->slots[i].count; + if (need > 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].entries = (spatial_entry_t*)ALLOC( + (size_t)need * sizeof(spatial_entry_t)); + grid->slots[i].capacity = need; } - grid->slots[i].count = 0; // reset for fill phase + grid->slots[i].count = 0; } - // Phase 3: fill entries + // Phase 3: fill entries — each shape is added to every cell its AABB overlaps 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); + shape_t *s = (shape_t*)vec_get(shapes, i); + float min_x = s->cx - s->aabb_hx; + float min_y = s->cy - s->aabb_hy; + float max_x = s->cx + s->aabb_hx; + float max_y = s->cy + s->aabb_hy; - int idx = spatial_hash(ccx, ccy) & (SPATIAL_HASH_SIZE - 1); - int probe = 0; - while (!(grid->slots[idx].occupied && - grid->slots[idx].cx == ccx && grid->slots[idx].cy == ccy) && - probe < SPATIAL_HASH_SIZE) { - idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); - probe++; + int nc = spatial_shape_cells(s, cell_xs, cell_ys); + for (int c = 0; c < nc; c++) { + int idx = spatial_find_slot(grid, cell_xs[c], cell_ys[c]); + spatial_entry_t *e = &grid->slots[idx].entries[grid->slots[idx].count++]; + e->shape_idx = i; + e->min_x = min_x; e->min_y = min_y; + e->max_x = max_x; e->max_y = max_y; } - - 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); } } +// Point query — O(1) average. Only checks the cell containing the query point +// because shapes are now inserted into every cell their AABB overlaps. 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); + int cx = (int)floorf(wx / SPATIAL_CELL_SIZE); + int cy = (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(cx, cy) & (SPATIAL_HASH_SIZE - 1); + int probe_start = idx; - int idx = spatial_hash(cell_x, cell_y) & (SPATIAL_HASH_SIZE - 1); - int probe_start = idx; + do { + if (!grid->slots[idx].occupied) break; - do { - if (!grid->slots[idx].occupied) break; + if (grid->slots[idx].cx == cx && grid->slots[idx].cy == cy) { + for (int e = 0; e < grid->slots[idx].count; e++) { + spatial_entry_t *entry = &grid->slots[idx].entries[e]; - 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; - 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); + 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; } +// Rectangle selection for marquee. Uses a per-query frame counter for dedup +// since shapes now appear in every cell they overlap. 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 n = shapes->count; + for (int i = 0; i < n; i++) { + ((shape_t*)vec_get(shapes, i))->selected = false; } int selected_count = 0; - for (int s = 0; s < SPATIAL_HASH_SIZE; s++) { - if (!grid->slots[s].occupied) continue; - for (int e = 0; e < grid->slots[s].count; e++) { - spatial_entry_t *entry = &grid->slots[s].entries[e]; + grid->query_frame++; + int frame = grid->query_frame; - if (entry->max_x < min_x || entry->min_x > max_x || - entry->max_y < min_y || entry->min_y > max_y) - continue; + int cell_min_x = (int)floorf(min_x / SPATIAL_CELL_SIZE); + int cell_max_x = (int)floorf(max_x / SPATIAL_CELL_SIZE); + int cell_min_y = (int)floorf(min_y / SPATIAL_CELL_SIZE); + int cell_max_y = (int)floorf(max_y / SPATIAL_CELL_SIZE); - shape_t *shape = (shape_t*) vec_get(shapes, entry->shape_idx); - if (shape->selected) continue; + int cell_count = (cell_max_x - cell_min_x + 1) * (cell_max_y - cell_min_y + 1); + if (cell_count > SPATIAL_HASH_SIZE) { + for (int s = 0; s < SPATIAL_HASH_SIZE; s++) { + if (!grid->slots[s].occupied) continue; + for (int e = 0; e < grid->slots[s].count; e++) { + spatial_entry_t *entry = &grid->slots[s].entries[e]; + if (grid->visited[entry->shape_idx] == frame) continue; + grid->visited[entry->shape_idx] = frame; - bool hit = (shape->cx >= min_x && shape->cx <= max_x && - shape->cy >= min_y && shape->cy <= max_y); - float sc = shape->cos_r, ss = shape->sin_r; - for (uint32_t v = 0; !hit && v < shape->num_verts; v++) { - float lx = shape->verts[v].x * shape->sx; - float ly = shape->verts[v].y * shape->sy; - float wx = shape->cx + lx * sc - ly * ss; - float wy = shape->cy + lx * ss + ly * sc; - if (wx >= min_x && wx <= max_x && - wy >= min_y && wy <= max_y) - hit = true; - } - if (hit) { - shape->selected = true; - selected_count++; + if (entry->max_x < min_x || entry->min_x > max_x || + entry->max_y < min_y || entry->min_y > max_y) + continue; + shape_t *shape = (shape_t*)vec_get(shapes, entry->shape_idx); + bool hit = (shape->cx >= min_x && shape->cx <= max_x && + shape->cy >= min_y && shape->cy <= max_y); + float sx_cos = shape->sx * shape->cos_r; + float sy_sin = shape->sy * shape->sin_r; + float sx_sin = shape->sx * shape->sin_r; + float sy_cos = shape->sy * shape->cos_r; + for (uint32_t v = 0; !hit && v < shape->num_verts; v++) { + float wx = shape->cx + shape->verts[v].x * sx_cos - shape->verts[v].y * sy_sin; + float wy = shape->cy + shape->verts[v].x * sx_sin + shape->verts[v].y * sy_cos; + if (wx >= min_x && wx <= max_x && + wy >= min_y && wy <= max_y) + hit = true; + } + if (hit) { shape->selected = true; selected_count++; } } } + return selected_count; + } + + for (int cy = cell_min_y; cy <= cell_max_y; cy++) { + for (int cx = cell_min_x; cx <= cell_max_x; cx++) { + int idx = spatial_hash(cx, cy) & (SPATIAL_HASH_SIZE - 1); + int probe_start = idx; + do { + if (!grid->slots[idx].occupied) break; + if (grid->slots[idx].cx == cx && grid->slots[idx].cy == cy) { + for (int e = 0; e < grid->slots[idx].count; e++) { + spatial_entry_t *entry = &grid->slots[idx].entries[e]; + if (grid->visited[entry->shape_idx] == frame) continue; + grid->visited[entry->shape_idx] = frame; + + if (entry->max_x < min_x || entry->min_x > max_x || + entry->max_y < min_y || entry->min_y > max_y) + continue; + shape_t *shape = (shape_t*)vec_get(shapes, entry->shape_idx); + bool hit = (shape->cx >= min_x && shape->cx <= max_x && + shape->cy >= min_y && shape->cy <= max_y); + float sc = shape->cos_r, ss = shape->sin_r; + for (uint32_t v = 0; !hit && v < shape->num_verts; v++) { + float lx = shape->verts[v].x * shape->sx; + float ly = shape->verts[v].y * shape->sy; + float wx = shape->cx + lx * sc - ly * ss; + float wy = shape->cy + lx * ss + ly * sc; + if (wx >= min_x && wx <= max_x && + wy >= min_y && wy <= max_y) + hit = true; + } + if (hit) { shape->selected = true; selected_count++; } + } + break; + } + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + } while (idx != probe_start); + } } return selected_count; } +static int spatial_query_viewport(spatial_grid_t *grid, + float min_x, float min_y, float max_x, float max_y, + int *out_indices, int max_out) +{ + grid->query_frame++; + int frame = grid->query_frame; + + int cell_min_x = (int)floorf(min_x / SPATIAL_CELL_SIZE); + int cell_max_x = (int)floorf(max_x / SPATIAL_CELL_SIZE); + int cell_min_y = (int)floorf(min_y / SPATIAL_CELL_SIZE); + int cell_max_y = (int)floorf(max_y / SPATIAL_CELL_SIZE); + + int count = 0; + for (int cy = cell_min_y; cy <= cell_max_y && count < max_out; cy++) { + for (int cx = cell_min_x; cx <= cell_max_x && count < max_out; cx++) { + int idx = spatial_hash(cx, cy) & (SPATIAL_HASH_SIZE - 1); + int probe_start = idx; + do { + if (!grid->slots[idx].occupied) break; + if (grid->slots[idx].cx == cx && grid->slots[idx].cy == cy) { + for (int e = 0; e < grid->slots[idx].count && count < max_out; e++) { + spatial_entry_t *entry = &grid->slots[idx].entries[e]; + if (grid->visited[entry->shape_idx] == frame) continue; + grid->visited[entry->shape_idx] = frame; + + if (entry->max_x < min_x || entry->min_x > max_x || + entry->max_y < min_y || entry->min_y > max_y) + continue; + out_indices[count++] = entry->shape_idx; + } + break; + } + idx = (idx + 1) & (SPATIAL_HASH_SIZE - 1); + } while (idx != probe_start); + } + } + return count; +} + #endif diff --git a/src/types.h b/src/types.h index 98fcef8..2674625 100644 --- a/src/types.h +++ b/src/types.h @@ -3,12 +3,23 @@ #include "api.h" -#define LOG_RING_SIZE 64 +#define LOG_RING_SIZE 256 #define HANDLE_OFFSET_PX 0.0f #define HANDLE_RADIUS_PX 12.0f -#define HANDLE_CIRCLE_SEGMENTS 256 +#define HANDLE_CIRCLE_SEGMENTS 64 #define CORNER_SIZE_PX 8.0f #define TOP_PANEL_H 32.0f +#define PEN_MAX_CONTROL_POINTS 256 +#define PEN_PREVIEW_MAX_VERTS 2048 +#define PEN_CLOSE_PX 10.0f +#define EDIT_ANCHOR_SIZE_PX 8.0f +#define EDIT_HANDLE_SIZE_PX 5.0f + +#define DOUBLE_CLICK_TIME 0.3 +#define DRAG_THRESHOLD_SQ 9.0f +#define FRUSTUM_CULL_MARGIN 300.0f +#define CAMERA_ZOOM_MIN 0.1f +#define CAMERA_ZOOM_MAX 6.0f typedef enum { TOOL_SELECT, @@ -21,6 +32,7 @@ typedef enum { typedef struct log_entry_t { char text[256]; uint32_t level; + uint64_t hash; } log_entry_t; typedef struct { @@ -86,11 +98,23 @@ typedef struct { float cached_aabb[4]; bool aabb_cached; - int focused_group_id; double last_click_time; int last_click_shape_idx; vector_t drag_indices; + + // Edit mode + int editing_shape_idx; + bool edit_dragging; + int edit_drag_idx; + bool edit_handle_dragging; + int edit_handle_idx; + bool edit_handle_is_in; + // Pre-drag control point snapshot (for undo) + shape_vertex_t *edit_saved_ctrl; + shape_vertex_t *edit_saved_hin; + shape_vertex_t *edit_saved_hout; + int edit_saved_count; } interact_state_t; typedef struct { @@ -109,36 +133,70 @@ typedef struct { int log_head; int log_count; bool log_show; + char log_filter[32]; tool_t active_tool; int list_last_shape; int list_prev_count; + int *display_cache; + int display_cache_len; + bool display_cache_dirty; } ui_state_t; typedef struct { shape_t *shapes; int shape_count; - group_t *groups; - int group_count; } clipboard_t; +typedef struct { + bool drawing; + shape_vertex_t points[PEN_MAX_CONTROL_POINTS]; + int point_count; + shape_vertex_t preview_verts[PEN_PREVIEW_MAX_VERTS]; + int preview_count; +} pen_state_t; + +// Per-overlay-buffer upload flags — replaces the single overlay_upload_needed +// bool so that during drag we only upload the buffers that actually changed +// (e.g. moving shapes only needs rect+corners, not edit-mode buffers). +typedef struct { + bool rect; + bool handle_circle; + bool corners; + bool edit_anchors; + bool edit_handles; + bool edit_lines; + bool pen; +} overlay_upload_flags_t; + typedef struct userdata_t { camera_t camera; renderer_t renderer; + shape_pool_ctx_t shape_pool; + group_index_ctx_t group_idx; + pipeline_ctx_t pipelines; + panel_log_ctx_t panel_log_ctx; + rand_ctx_t rand_ctx; vector_t shapes; spatial_grid_t spatial_grid; interact_state_t interact; history_t history; debug_stats_t debug; ui_state_t ui; - bool overlay_upload_needed; + overlay_upload_flags_t overlay_upload; sg_buffer rect_vbuf, rect_ibuf; sg_buffer handle_vbuf, handle_ibuf; sg_buffer corner_vbuf, corner_ibuf; int next_group_id; vector_t groups; clipboard_t clipboard; + float map_w, map_h; float mouse_x, mouse_y; double time; + pen_state_t pen; + sg_buffer pen_vbuf, pen_ibuf; + // Edit mode buffers + sg_buffer ed_anchor_vbuf, ed_handle_vbuf, ed_handle_line_vbuf, ed_shared_ibuf; + int ed_anchor_count, ed_handle_count, ed_handle_line_count; } userdata_t; #endif diff --git a/src/ui_panels.h b/src/ui_panels.h index 7ba4920..132c95e 100644 --- a/src/ui_panels.h +++ b/src/ui_panels.h @@ -5,13 +5,9 @@ #include "types.h" #include "interact.h" -static const char *shape_kind_label(int kind) { - switch (kind) { - case SHAPE_CIRCLE: return "Circle"; - case SHAPE_RECTANGLE: return "Rect"; - case SHAPE_STAR: return "Star"; - default: return "Shape"; - } +static const char *shape_kind_label(const char *name) { + if (name[0]) return name; + return "Shape"; } static void build_display_recursive(vector_t *shapes, vector_t *groups, int parent_gid, int *display, int *dlen) @@ -19,10 +15,8 @@ static void build_display_recursive(vector_t *shapes, vector_t *groups, int pare for (int g = 0; g < groups->count; g++) { group_t *grp = (group_t*) vec_get(groups, g); if (grp->parent_id != parent_gid) continue; - for (int i = 0; i < shapes->count; i++) { - if (((shape_t*) vec_get(shapes, i))->group_id == grp->id) - display[(*dlen)++] = i; - } + for (int m = 0; m < grp->member_count; m++) + display[(*dlen)++] = grp->member_indices[m]; build_display_recursive(shapes, groups, grp->id, display, dlen); } if (parent_gid == 0) { @@ -33,13 +27,28 @@ static void build_display_recursive(vector_t *shapes, vector_t *groups, int pare } } -static int count_shapes_in_subtree(vector_t *shapes, vector_t *groups, int gid) +// Count shapes and group headers in a subtree (for collapsed path). +static void count_subtree_items(vector_t *groups, int gid, int *shapes_out, int *headers_out) { - int c = 0; - for (int i = 0; i < shapes->count; i++) - if (is_shape_in_group_hierarchy(((shape_t*) vec_get(shapes, i))->group_id, gid, groups)) - c++; - return c; + int s = 0, h = 0; + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->id == gid) { + s = grp->member_count; + for (int k = 0; k < groups->count; k++) { + group_t *child = (group_t*) vec_get(groups, k); + if (child->parent_id == gid) { + h += 1; + int cs, ch; + count_subtree_items(groups, child->id, &cs, &ch); + s += cs; h += ch; + } + } + break; + } + } + *shapes_out = s; + *headers_out = h; } static void list_shape_clicked(userdata_t *ud, shape_t *s, int *display, int display_len, int display_pos) @@ -67,95 +76,144 @@ static void list_shape_clicked(userdata_t *ud, shape_t *s, int *display, int dis for (int j = 0; j < n; j++) ((shape_t*) vec_get(&ud->shapes, j))->selected = false; ud->interact.selected_count = 0; - if (s->group_id != 0) { - int topmost = get_topmost_group(&ud->groups, s->group_id); - for (int j = 0; j < n; j++) { - shape_t *sj = (shape_t*) vec_get(&ud->shapes, j); - if (is_shape_in_group_hierarchy(sj->group_id, topmost, &ud->groups)) { - sj->selected = true; - ud->interact.selected_count++; - } - } - } else { - s->selected = true; - ud->interact.selected_count = 1; - } + s->selected = true; + ud->interact.selected_count = 1; } ud->ui.list_last_shape = display_pos; ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); update_shape_states(ud); } -static int render_tree_level(userdata_t *ud, int parent_gid, int *display, int display_len, int display_pos) +// Count items that are currently visible (respecting collapse state). +// Used to compute the exact scrollbar range. +static int count_visible_items(vector_t *groups, int parent_gid) +{ + int c = 0; + for (int g = 0; g < groups->count; g++) { + group_t *grp = (group_t*) vec_get(groups, g); + if (grp->parent_id != parent_gid) continue; + c += 1; // group header (always visible) + if (!grp->collapsed) { + c += grp->member_count; + c += count_visible_items(groups, grp->id); + } + } + return c; +} + +// Render tree level with virtualized scrolling (ocornut's technique from imgui#3823). +// `item_idx` tracks only VISIBLE items (collapsed subtrees don't contribute) +// so that the scrollbar accurately reflects the viewable content. `pos` always +// tracks the display-array position for shift-click range selection. +static int render_tree_level(userdata_t *ud, int parent_gid, int *display, int display_len, + int display_pos, int first_visible, int last_visible, int *item_idx) { int n = ud->shapes.count; int pos = display_pos; + bool has_groups = (ud->groups.count > 0); - for (int g = 0; g < ud->groups.count; g++) { - group_t *grp = (group_t*) vec_get(&ud->groups, g); - if (grp->parent_id != parent_gid) continue; + if (has_groups) { + for (int g = 0; g < ud->groups.count; g++) { + group_t *grp = (group_t*) vec_get(&ud->groups, g); + if (grp->parent_id != parent_gid) continue; - int gid = grp->id; + int gid = grp->id; + bool visible = (*item_idx >= first_visible && *item_idx < last_visible); + (*item_idx)++; - int member_count = 0, sel_count = 0; - for (int i = 0; i < n; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->group_id == gid) { member_count++; if (s->selected) sel_count++; } - } + if (visible) { + // --- VISIBLE GROUP HEADER --- + ImGuiID storage_id = (ImGuiID)gid; + igSetNextItemStorageID(storage_id); - char hdr[128]; - snprintf(hdr, sizeof(hdr), "Group %d (%d)##g%d", gid, member_count, gid); + char hdr[64]; + snprintf(hdr, sizeof(hdr), "Group##g%d", gid); + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow; + if (!grp->collapsed) flags |= ImGuiTreeNodeFlags_DefaultOpen; + bool open = igTreeNodeEx_Str(hdr, flags); + grp->collapsed = !open; - ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_DefaultOpen; + if (igIsItemClicked(ImGuiMouseButton_Left)) { + bool ctrl = igGetIO_Nil()->KeyCtrl; + if (ctrl) + toggle_group_recursive(ud, gid); + else + deselect_and_select_group_recursive(ud, gid); + ud->ui.list_last_shape = display_pos; + ud->interact.aabb_cached = false; + overlay_invalidate(ud); + update_shape_states(ud); + } - bool open = igTreeNodeEx_Str(hdr, flags); + if (open) { + for (int m = 0; m < grp->member_count; m++) { + int si = grp->member_indices[m]; + bool child_visible = (*item_idx >= first_visible && *item_idx < last_visible); + (*item_idx)++; - int group_first = pos; + if (child_visible) { + shape_t *s = (shape_t*) vec_get(&ud->shapes, si); + char label[128]; + snprintf(label, sizeof(label), " %s##s%d", shape_kind_label(s->name), si); + if (igSelectable_Bool(label, s->selected, ImGuiSelectableFlags_None, (ImVec2){0,0})) + list_shape_clicked(ud, s, display, display_len, pos); + } + pos++; + } + pos = render_tree_level(ud, gid, display, display_len, pos, + first_visible, last_visible, item_idx); + igTreePop(); + } + // Closed node: no TreePop — TreePushOverrideID was not called. + // Also no item_idx advance for the hidden subtree — only the + // header counts toward the visible-total. Still advance pos + // for display-index accuracy. + if (!open) { + int cs, ch; + count_subtree_items(&ud->groups, gid, &cs, &ch); + pos += cs; + // item_idx NOT advanced: collapsed items are not visible + } + } else { + // --- CLIPPED (OFF-SCREEN) GROUP --- + if (grp->collapsed) { + int cs, ch; + count_subtree_items(&ud->groups, gid, &cs, &ch); + pos += cs; + // item_idx already incremented for header; collapsed + // subtree adds nothing (not visible). + } else { + // Open but clipped: TreePush walks subtree via recursion. + *item_idx += grp->member_count; + pos += grp->member_count; - if (igIsItemClicked(ImGuiMouseButton_Left)) { - bool ctrl = igGetIO_Nil()->KeyCtrl; - if (ctrl) - toggle_group_recursive(ud, gid); - else - deselect_and_select_group_recursive(ud, gid); - ud->ui.list_last_shape = group_first; - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; - update_shape_states(ud); - } - - if (open) { - for (int i = 0; i < n; i++) { - shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->group_id != gid) continue; - - char label[128]; - snprintf(label, sizeof(label), " %s##s%d", shape_kind_label(s->kind), i); - - if (igSelectable_Bool(label, s->selected, ImGuiSelectableFlags_None, (ImVec2){0,0})) - list_shape_clicked(ud, s, display, display_len, pos); - - pos++; + char label[64]; + snprintf(label, sizeof(label), "Group##g%d", gid); + igTreePush_Str(label); + pos = render_tree_level(ud, gid, display, display_len, pos, + first_visible, last_visible, item_idx); + igTreePop(); + } } - pos = render_tree_level(ud, gid, display, display_len, pos); - igTreePop(); - } else { - pos += count_shapes_in_subtree(&ud->shapes, &ud->groups, gid); } } + // Ungrouped shapes — only at the top level if (parent_gid == 0) { for (int i = 0; i < n; i++) { shape_t *s = (shape_t*) vec_get(&ud->shapes, i); - if (s->group_id != 0) continue; + if (has_groups && s->group_id != 0) continue; - char label[128]; - snprintf(label, sizeof(label), "%s##s%d", shape_kind_label(s->kind), i); - - if (igSelectable_Bool(label, s->selected, ImGuiSelectableFlags_None, (ImVec2){0,0})) - list_shape_clicked(ud, s, display, display_len, pos); + bool in_range = (*item_idx >= first_visible && *item_idx < last_visible); + (*item_idx)++; + if (in_range) { + char label[128]; + snprintf(label, sizeof(label), "%s##s%d", shape_kind_label(s->name), i); + if (igSelectable_Bool(label, s->selected, ImGuiSelectableFlags_None, (ImVec2){0,0})) + list_shape_clicked(ud, s, display, display_len, pos); + } pos++; } } @@ -194,7 +252,7 @@ static void draw_top_panel(userdata_t *ud) ((shape_t*)vec_get(&ud->shapes, i))->selected = false; } ud->interact.selected_count = 0; - ud->overlay_upload_needed = true; + overlay_invalidate(ud); update_shape_states(ud); } ud->ui.active_tool = new_tool; @@ -206,34 +264,23 @@ static void draw_top_panel(userdata_t *ud) igSameLine(0.0f, 16.0f); if (igButton("Undo", (ImVec2){0, 0})) { - if (history_undo(&ud->history, &ud->shapes)) { - rebuild_groups_from_shapes(&ud->groups, &ud->shapes); - ud->interact.hovered_shape = -1; - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + if (history_undo(&ud->history, &ud->shapes, &ud->shape_pool, + &ud->groups, &ud->group_idx)) { + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); + interact_structural_change(ud); } } igSameLine(0.0f, 4.0f); if (igButton("Redo", (ImVec2){0, 0})) { - if (history_redo(&ud->history, &ud->shapes)) { - rebuild_groups_from_shapes(&ud->groups, &ud->shapes); - ud->interact.hovered_shape = -1; - spatial_mark_dirty(&ud->spatial_grid); - ud->interact.aabb_cached = false; - ud->overlay_upload_needed = true; + if (history_redo(&ud->history, &ud->shapes, &ud->shape_pool, + &ud->groups, &ud->group_idx)) { + group_rebuild_members(&ud->group_idx, &ud->groups, &ud->shapes); + interact_structural_change(ud); } } - if (ud->interact.focused_group_id != 0) { - igSameLine(0.0f, 16.0f); - char flbl[64]; - snprintf(flbl, sizeof(flbl), "Focus: Group %d (Esc)", ud->interact.focused_group_id); - igTextColored((ImVec4){0.3f, 1.0f, 0.3f, 1.0f}, "%s", flbl); - } - igEnd(); } @@ -252,18 +299,49 @@ static void draw_shape_list_panel(userdata_t *ud) return; } - igBeginChild_Str("ListScroll", (ImVec2){0, 0}, false, ImGuiWindowFlags_None); - - int *display = (int*) ALLOC((size_t)n * sizeof(int)); - int display_len = 0; - build_display_recursive(&ud->shapes, &ud->groups, 0, display, &display_len); + if (ud->ui.display_cache_dirty || ud->ui.display_cache_len != n) { + FREE(ud->ui.display_cache); + ud->ui.display_cache = (int*) ALLOC((size_t)n * sizeof(int)); + ud->ui.display_cache_len = 0; + build_display_recursive(&ud->shapes, &ud->groups, 0, ud->ui.display_cache, &ud->ui.display_cache_len); + ud->ui.display_cache_dirty = false; + } + int *display = ud->ui.display_cache; + int display_len = ud->ui.display_cache_len; if (n != ud->ui.list_prev_count) { ud->ui.list_last_shape = -1; ud->ui.list_prev_count = n; } if (ud->ui.list_last_shape >= display_len) ud->ui.list_last_shape = -1; - render_tree_level(ud, 0, display, display_len, 0); + // Count only visible items (respects collapse state) for correct scrollbar. + int total_items = count_visible_items(&ud->groups, 0); + for (int i = 0; i < n; i++) { + if (((shape_t*) vec_get(&ud->shapes, i))->group_id == 0) + total_items++; + } + + igBeginChild_Str("ListScroll", (ImVec2){0, 0}, false, ImGuiWindowFlags_None); + + float line_h = igGetTextLineHeightWithSpacing(); + float scroll_y = igGetScrollY(); + int first_visible = (int)(scroll_y / line_h); + if (first_visible < 0) first_visible = 0; + int visible_slack = (int)(igGetWindowHeight() / line_h) + 4; + int last_visible = first_visible + visible_slack; + if (last_visible > total_items) last_visible = total_items; + if (first_visible > last_visible) first_visible = last_visible; + + // Position cursor at first visible item, render, then set total content height. + float cursor_base = igGetCursorPosY(); + igSetCursorPosY(cursor_base + first_visible * line_h); + + int item_idx = 0; + render_tree_level(ud, 0, display, display_len, 0, + first_visible, last_visible, &item_idx); + + // Stretch content height so the scrollbar reflects the total visible items. + igSetCursorPosY(cursor_base + total_items * line_h); + igDummy((ImVec2){0, 0}); - FREE(display); igEndChild(); igEnd(); } @@ -314,7 +392,7 @@ static void draw_properties_panel(userdata_t *ud) } else { strcpy(path, seg); } - group_t *g = find_group(&ud->groups, gid); + group_t *g = find_group(&ud->group_idx, &ud->groups, gid); gid = g ? g->parent_id : 0; } int members = 0; @@ -327,12 +405,12 @@ static void draw_properties_panel(userdata_t *ud) changed |= igDragFloat2("Position", &s->cx, 1.0f, 0, 0, "%.1f", 0); if (igIsItemActivated()) history_begin_edit(&ud->history, &ud->shapes, idx, HIST_POSITION); - changed |= igDragFloat2("Scale", &s->sx, 1.0f, 0.1f, 0, "%.1f", 0); + changed |= igDragFloat2("Scale", &s->sx, 1.0f, -10.0f, 10.0f, "%.1f", 0); if (igIsItemActivated()) history_begin_edit(&ud->history, &ud->shapes, idx, HIST_SCALE); changed |= igDragFloat("Rotation", &s->rotation, 0.01f, 0, 0, "%.3f", 0); if (igIsItemActivated()) history_begin_edit(&ud->history, &ud->shapes, idx, HIST_ROTATION); - if (changed) { shape_regenerate(s); spatial_mark_dirty(&ud->spatial_grid); ud->overlay_upload_needed = true; } + if (changed) { shape_regenerate(&ud->shape_pool, s); spatial_mark_dirty(&ud->spatial_grid); overlay_invalidate(ud); } igSeparator(); { @@ -389,9 +467,7 @@ static void draw_log_panel(userdata_t *ud) int idx = (start + i) % LOG_RING_SIZE; off += snprintf(buf + off, (size_t)(cap - off), "%s\n", ud->ui.log_ring[idx].text); } - EM_ASM({ - navigator.clipboard.writeText(UTF8ToString($0)); - }, buf); + sapp_set_clipboard_string(buf); FREE(buf); } igSameLine(0.0f, 10.0f); diff --git a/src/util.h b/src/util.h index 15d6261..5f90cbe 100644 --- a/src/util.h +++ b/src/util.h @@ -33,7 +33,11 @@ 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; uint8_t *new_data = (uint8_t*) ALLOC(new_cap * v->stride); - assert(new_data != NULL); + if (!new_data) { + EM_ASM({ console.error("vec_grow: ALLOC failed for %d elements of %d bytes", $0, $1); }, + new_cap, v->stride); + return; + } if (v->data) { memcpy(new_data, v->data, v->count * v->stride); FREE(v->data); @@ -72,6 +76,25 @@ static void vec_remove_ordered(vector_t *v, int index) { v->count--; } +// Remove `count` elements at given indices in a single compaction pass. +// Indices must be sorted in ascending order and must be valid. +static void vec_remove_ordered_bulk(vector_t *v, const int *indices, int count) { + if (count <= 0) return; + int write = indices[0]; + for (int k = 0; k < count; k++) { + int gap_start = indices[k]; + int gap_end = (k + 1 < count) ? indices[k + 1] : v->count; + int keep = gap_end - gap_start - 1; + if (keep > 0) { + memmove(v->data + write * v->stride, + v->data + (gap_start + 1) * v->stride, + (size_t)keep * (size_t)v->stride); + write += keep; + } + } + v->count -= count; +} + static void *vec_insert(vector_t *v, int index) { if (index < 0 || index > v->count) return NULL; if (v->count >= v->capacity) vec_grow(v, v->count + 1);