Cartograph
A browser-based world map creation tool inspired by Wonderdraft and Inkarnate. Uses line-strip vector shapes with procedural geometry.
Features
- 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
- 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
| Layer | Library |
|---|---|
| Language | C (C99) |
| Compiler | Emscripten (emcc) → WebAssembly |
| Graphics | Sokol with WebGPU backend |
| UI | Dear ImGui via cimgui |
| Math | cglm |
| Shaders | WGSL (compiled to C headers via xxd -i) |
Build
# Install dependencies
./fetch_libs.sh
# Release build
make
# Debug build
make debug
Output is app.html, served by Emscripten's built-in web server or any static server.
Project structure
src/
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, memtrack)
imgui/ Dear ImGui + cimgui
cglm/ C linear math library
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_ctrlupdates the hash and modifies the group buffer in-place viasg_update_bufferrather 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