diff --git a/.zed/tasks.json b/.zed/tasks.json index 13a618b..be18c0b 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -55,6 +55,11 @@ "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- hellope-shapes", "cwd": "$ZED_WORKTREE_ROOT", }, + { + "label": "Run draw hellope-text example", + "command": "odin run draw/examples -debug -out=out/debug/draw-examples -- hellope-text", + "cwd": "$ZED_WORKTREE_ROOT", + }, // --------------------------------------------------------------------------------------------------------------------- // ----- Other ------------------------ // --------------------------------------------------------------------------------------------------------------------- diff --git a/draw/draw.odin b/draw/draw.odin index 457a938..4f9f95c 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -1,14 +1,15 @@ package draw -import clay "../vendor/clay" import "base:runtime" import "core:c" import "core:log" - +import "core:math" import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" +import clay "../vendor/clay" + when ODIN_OS == .Darwin { SHADER_TYPE :: sdl.GPUShaderFormat{.MSL} ENTRY_POINT :: "main0" @@ -89,34 +90,35 @@ Scissor :: struct { GLOB: Global Global :: struct { - odin_context: runtime.Context, - pipeline_2d_base: Pipeline_2D_Base, - text_cache: Text_Cache, - layers: [dynamic]Layer, - scissors: [dynamic]Scissor, - tmp_shape_verts: [dynamic]Vertex, - tmp_text_verts: [dynamic]Vertex, - tmp_text_indices: [dynamic]c.int, - tmp_text_batches: [dynamic]TextBatch, - tmp_primitives: [dynamic]Primitive, - tmp_sub_batches: [dynamic]Sub_Batch, - clay_mem: [^]u8, - msaa_texture: ^sdl.GPUTexture, - curr_layer_index: uint, - max_layers: int, - max_scissors: int, - max_shape_verts: int, - max_text_verts: int, - max_text_indices: int, - max_text_batches: int, - max_primitives: int, - max_sub_batches: int, - dpi_scaling: f32, - msaa_w: u32, - msaa_h: u32, - sample_count: sdl.GPUSampleCount, - clay_z_index: i16, - cleared: bool, + odin_context: runtime.Context, + pipeline_2d_base: Pipeline_2D_Base, + text_cache: Text_Cache, + layers: [dynamic]Layer, + scissors: [dynamic]Scissor, + tmp_shape_verts: [dynamic]Vertex, + tmp_text_verts: [dynamic]Vertex, + tmp_text_indices: [dynamic]c.int, + tmp_text_batches: [dynamic]TextBatch, + tmp_primitives: [dynamic]Primitive, + tmp_sub_batches: [dynamic]Sub_Batch, + tmp_uncached_text: [dynamic]^sdl_ttf.Text, // Uncached TTF_Text objects to destroy after end() + clay_mem: [^]u8, + msaa_texture: ^sdl.GPUTexture, + curr_layer_index: uint, + max_layers: int, + max_scissors: int, + max_shape_verts: int, + max_text_verts: int, + max_text_indices: int, + max_text_batches: int, + max_primitives: int, + max_sub_batches: int, + dpi_scaling: f32, + msaa_w: u32, + msaa_h: u32, + sample_count: sdl.GPUSampleCount, + clay_z_index: i16, + cleared: bool, } Init_Options :: struct { @@ -161,20 +163,21 @@ init :: proc( } GLOB = Global { - layers = make([dynamic]Layer, 0, INITIAL_LAYER_SIZE, allocator = allocator), - scissors = make([dynamic]Scissor, 0, INITIAL_SCISSOR_SIZE, allocator = allocator), - tmp_shape_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_indices = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_text_batches = make([dynamic]TextBatch, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_primitives = make([dynamic]Primitive, 0, BUFFER_INIT_SIZE, allocator = allocator), - tmp_sub_batches = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator), - odin_context = odin_context, - dpi_scaling = sdl.GetWindowDisplayScale(window), - clay_mem = make([^]u8, min_memory_size, allocator = allocator), - sample_count = resolved_sample_count, - pipeline_2d_base = pipeline, - text_cache = text_cache, + layers = make([dynamic]Layer, 0, INITIAL_LAYER_SIZE, allocator = allocator), + scissors = make([dynamic]Scissor, 0, INITIAL_SCISSOR_SIZE, allocator = allocator), + tmp_shape_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_text_verts = make([dynamic]Vertex, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_text_indices = make([dynamic]c.int, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_text_batches = make([dynamic]TextBatch, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_primitives = make([dynamic]Primitive, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_sub_batches = make([dynamic]Sub_Batch, 0, BUFFER_INIT_SIZE, allocator = allocator), + tmp_uncached_text = make([dynamic]^sdl_ttf.Text, 0, 16, allocator = allocator), + odin_context = odin_context, + dpi_scaling = sdl.GetWindowDisplayScale(window), + clay_mem = make([^]u8, min_memory_size, allocator = allocator), + sample_count = resolved_sample_count, + pipeline_2d_base = pipeline, + text_cache = text_cache, } log.debug("Window DPI scaling:", GLOB.dpi_scaling) arena := clay.CreateArenaWithCapacityAndMemory(min_memory_size, GLOB.clay_mem) @@ -182,7 +185,7 @@ init :: proc( sdl.GetWindowSize(window, &window_width, &window_height) clay.Initialize(arena, {f32(window_width), f32(window_height)}, {handler = clay_error_handler}) - clay.SetMeasureTextFunction(measure_text, nil) + clay.SetMeasureTextFunction(measure_text_clay, nil) return true } @@ -216,6 +219,8 @@ destroy :: proc(device: ^sdl.GPUDevice, allocator := context.allocator) { delete(GLOB.tmp_text_batches) delete(GLOB.tmp_primitives) delete(GLOB.tmp_sub_batches) + for t in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(t) + delete(GLOB.tmp_uncached_text) free(GLOB.clay_mem, allocator) if GLOB.msaa_texture != nil { sdl.ReleaseGPUTexture(device, GLOB.msaa_texture) @@ -229,6 +234,9 @@ clear_global :: proc() { GLOB.curr_layer_index = 0 GLOB.clay_z_index = 0 GLOB.cleared = false + // Destroy uncached TTF_Text objects from the previous frame (after end() has submitted draw data) + for t in GLOB.tmp_uncached_text do sdl_ttf.DestroyText(t) + clear(&GLOB.tmp_uncached_text) clear(&GLOB.layers) clear(&GLOB.scissors) clear(&GLOB.tmp_shape_verts) @@ -244,7 +252,7 @@ clear_global :: proc() { // --------------------------------------------------------------------------------------------------------------------- @(private = "file") -measure_text :: proc "c" ( +measure_text_clay :: proc "c" ( text: clay.StringSlice, config: ^clay.TextElementConfig, user_data: rawptr, @@ -384,6 +392,54 @@ prepare_text :: proc(layer: ^Layer, txt: Text) { } } +// Submit a text element with a 2D affine transform applied to vertices. +// Used by the high-level `text` proc when rotation or a non-zero origin is specified. +// NOTE: xform must be in physical (DPI-scaled) pixel space — the caller pre-scales +// pos and origin by GLOB.dpi_scaling before building the transform. +prepare_text_transformed :: proc(layer: ^Layer, txt: Text, xform: Transform_2D) { + data := sdl_ttf.GetGPUTextDrawData(txt.ref) + if data == nil { + return + } + + scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1] + + for data != nil { + vertex_start := u32(len(GLOB.tmp_text_verts)) + index_start := u32(len(GLOB.tmp_text_indices)) + + for i in 0 ..< data.num_vertices { + pos := data.xy[i] + uv := data.uv[i] + // SDL_ttf gives glyph positions in physical pixels relative to text origin. + // The transform is already in physical-pixel space (caller pre-scaled), + // so we apply directly — no per-vertex DPI divide/multiply. + append( + &GLOB.tmp_text_verts, + Vertex{position = apply_transform(xform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = txt.color}, + ) + } + + append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices]) + + batch_idx := u32(len(GLOB.tmp_text_batches)) + append( + &GLOB.tmp_text_batches, + TextBatch { + atlas_texture = data.atlas_texture, + vertex_start = vertex_start, + vertex_count = u32(data.num_vertices), + index_start = index_start, + index_count = u32(data.num_indices), + }, + ) + + append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1) + + data = data.next + } +} + // Append a new sub-batch or extend the last one if same kind and contiguous. @(private) append_or_extend_sub_batch :: proc( @@ -466,26 +522,13 @@ prepare_clay_batch :: proc( render_data := render_command.renderData.text txt := string(render_data.stringContents.chars[:render_data.stringContents.length]) c_text := strings.clone_to_cstring(txt, context.temp_allocator) - sdl_text := GLOB.text_cache.cache[render_command.id] - - if sdl_text == nil { - // Cache a SDL text object - sdl_text = sdl_ttf.CreateText( - GLOB.text_cache.engine, - get_font(render_data.fontId, render_data.fontSize), - c_text, - 0, - ) - if sdl_text == nil { - log.panicf("Failed to create SDL text for clay render command: %s", sdl.GetError()) - } - GLOB.text_cache.cache[render_command.id] = sdl_text - } else { - if !sdl_ttf.SetTextString(sdl_text, c_text, 0) { - log.panicf("Failed to update SDL text string: %s", sdl.GetError()) - } - } - + // Clay's render_command.id is already hashed with the same Jenkins algorithm + // as text_cache_hash, so it shares the same keyspace. + sdl_text := cache_get_or_update( + render_command.id, + c_text, + get_font(render_data.fontId, render_data.fontSize), + ) prepare_text(layer, Text{sdl_text, {bounds.x, bounds.y}, color_from_clay(render_data.textColor)}) case clay.RenderCommandType.Image: case clay.RenderCommandType.ScissorStart: @@ -749,3 +792,112 @@ destroy_buffer :: proc(device: ^sdl.GPUDevice, buffer: ^Buffer) { sdl.ReleaseGPUBuffer(device, buffer.gpu) sdl.ReleaseGPUTransferBuffer(device, buffer.transfer) } + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Transform ------------------------ +// --------------------------------------------------------------------------------------------------------------------- + +// 2x3 affine transform for 2D pivot-rotation. +// Used internally by rotation-aware drawing procs. +Transform_2D :: struct { + m00, m01: f32, // row 0: rotation/scale + m10, m11: f32, // row 1: rotation/scale + tx, ty: f32, // translation +} + +// Build a pivot-rotation transform. +// +// Semantics (raylib-style): +// The point whose local coordinates equal `origin` lands at `pos` in world space. +// The rest of the shape rotates around that pivot. +// +// Formula: p_world = pos + R(θ) · (p_local - origin) +// +// Parameters: +// pos – world-space position where the pivot lands. +// origin – pivot point in local space (measured from the shape's natural reference point). +// rotation_deg – rotation in degrees, counter-clockwise. +// +build_pivot_rot :: proc(pos: [2]f32, origin: [2]f32, rotation_deg: f32) -> Transform_2D { + rad := math.to_radians(rotation_deg) + c := math.cos(rad) + s := math.sin(rad) + return Transform_2D { + m00 = c, + m01 = -s, + m10 = s, + m11 = c, + tx = pos.x - (c * origin.x - s * origin.y), + ty = pos.y - (s * origin.x + c * origin.y), + } +} + +// Apply the transform to a local-space point, producing a world-space point. +apply_transform :: #force_inline proc(t: Transform_2D, p: [2]f32) -> [2]f32 { + return {t.m00 * p.x + t.m01 * p.y + t.tx, t.m10 * p.x + t.m11 * p.y + t.ty} +} + +// Fast-path check callers use BEFORE building a transform. +// Returns true if either the origin is non-zero or rotation is non-zero, +// meaning a transform actually needs to be computed. +needs_transform :: #force_inline proc(origin: [2]f32, rotation: f32) -> bool { + return origin != {0, 0} || rotation != 0 +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Procedure Groups ------------------------ +// --------------------------------------------------------------------------------------------------------------------- + +center_of :: proc { + center_of_rect, + center_of_triangle, + center_of_text, +} + +top_left_of :: proc { + top_left_of_rect, + top_left_of_triangle, + top_left_of_text, +} + +top_of :: proc { + top_of_rect, + top_of_triangle, + top_of_text, +} + +top_right_of :: proc { + top_right_of_rect, + top_right_of_triangle, + top_right_of_text, +} + +left_of :: proc { + left_of_rect, + left_of_triangle, + left_of_text, +} + +right_of :: proc { + right_of_rect, + right_of_triangle, + right_of_text, +} + +bottom_left_of :: proc { + bottom_left_of_rect, + bottom_left_of_triangle, + bottom_left_of_text, +} + +bottom_of :: proc { + bottom_of_rect, + bottom_of_triangle, + bottom_of_text, +} + +bottom_right_of :: proc { + bottom_right_of_rect, + bottom_right_of_triangle, + bottom_right_of_text, +} diff --git a/draw/examples/hellope.odin b/draw/examples/hellope.odin index 55dd446..7cdcba5 100644 --- a/draw/examples/hellope.odin +++ b/draw/examples/hellope.odin @@ -2,10 +2,8 @@ package examples import "../../draw" import "../../vendor/clay" -import "core:c" import "core:os" import sdl "vendor:sdl3" -import sdl_ttf "vendor:sdl3/ttf" JETBRAINS_MONO_REGULAR_RAW :: #load("fonts/JetBrainsMono-Regular.ttf") JETBRAINS_MONO_REGULAR: draw.Font_Id = max(draw.Font_Id) // Max so we crash if registration is forgotten @@ -17,21 +15,24 @@ hellope_shapes :: proc() { if !sdl.ClaimWindowForGPUDevice(gpu, window) do os.exit(1) if !draw.init(gpu, window) do os.exit(1) + spin_angle: f32 = 0 + for { defer free_all(context.temp_allocator) ev: sdl.Event for sdl.PollEvent(&ev) { if ev.type == .QUIT do return } + spin_angle += 1 base_layer := draw.begin({w = 500, h = 500}) // Background draw.rectangle(base_layer, {0, 0, 500, 500}, {40, 40, 40, 255}) - // Shapes demo + // ----- Shapes without rotation (existing demo) ----- draw.rectangle(base_layer, {20, 20, 200, 120}, {80, 120, 200, 255}) draw.rectangle_lines(base_layer, {20, 20, 200, 120}, draw.WHITE, thick = 2) - draw.rectangle_rounded(base_layer, {240, 20, 240, 120}, 0.3, {200, 80, 80, 255}) + draw.rectangle(base_layer, {240, 20, 240, 120}, {200, 80, 80, 255}, roundness = 0.3) draw.rectangle_gradient( base_layer, {20, 160, 460, 60}, @@ -41,15 +42,66 @@ hellope_shapes :: proc() { {255, 255, 0, 255}, ) - draw.circle(base_layer, {120, 320}, 60, {100, 200, 100, 255}) - draw.circle_lines(base_layer, {120, 320}, 60, draw.WHITE, thick = 2) - draw.circle_gradient(base_layer, {300, 320}, 60, {255, 200, 50, 255}, {200, 50, 50, 255}) - draw.ring(base_layer, {430, 320}, 30, 55, 0, 270, {100, 100, 220, 255}) + // ----- Rotation demos ----- - draw.triangle(base_layer, {60, 420}, {180, 480}, {20, 480}, {220, 180, 60, 255}) - draw.line(base_layer, {220, 420}, {460, 480}, {255, 255, 100, 255}, thick = 3) - draw.poly(base_layer, {350, 450}, 6, 40, {180, 100, 220, 255}, rotation = 30) - draw.poly_lines(base_layer, {350, 450}, 6, 40, draw.WHITE, rotation = 30, thick = 2) + // Rectangle rotating around its center + rect := draw.Rectangle{100, 320, 80, 50} + draw.rectangle( + base_layer, + rect, + {100, 200, 100, 255}, + origin = draw.center_of(rect), + rotation = spin_angle, + ) + draw.rectangle_lines( + base_layer, + rect, + draw.WHITE, + thick = 2, + origin = draw.center_of(rect), + rotation = spin_angle, + ) + + // Rounded rectangle rotating around its center + rrect := draw.Rectangle{230, 300, 100, 80} + draw.rectangle( + base_layer, + rrect, + {200, 100, 200, 255}, + roundness = 0.4, + origin = draw.center_of(rrect), + rotation = spin_angle, + ) + + // Ellipse rotating around its center (tilted ellipse) + draw.ellipse(base_layer, {410, 340}, 50, 30, {255, 200, 50, 255}, rotation = spin_angle) + + // Circle orbiting a point (moon orbiting planet) + planet_pos := [2]f32{100, 450} + moon_pos := planet_pos + {0, -40} + draw.circle(base_layer, planet_pos, 8, {200, 200, 200, 255}) // planet (stationary) + draw.circle(base_layer, moon_pos, 5, {100, 150, 255, 255}, origin = {0, 40}, rotation = spin_angle) // moon orbiting + + // Ring arc rotating in place + draw.ring(base_layer, {250, 450}, 15, 30, 0, 270, {100, 100, 220, 255}, rotation = spin_angle) + + // Triangle rotating around its center + tv1 := [2]f32{350, 420} + tv2 := [2]f32{420, 480} + tv3 := [2]f32{340, 480} + draw.triangle( + base_layer, + tv1, + tv2, + tv3, + {220, 180, 60, 255}, + origin = draw.center_of(tv1, tv2, tv3), + rotation = spin_angle, + ) + + // Polygon rotating around its center (already had rotation; now with origin for orbit) + draw.poly(base_layer, {460, 450}, 6, 30, {180, 100, 220, 255}, rotation = spin_angle) + draw.poly_lines(base_layer, {460, 450}, 6, 30, draw.WHITE, rotation = spin_angle, thick = 2) draw.end(gpu, window) } @@ -57,17 +109,14 @@ hellope_shapes :: proc() { hellope_text :: proc() { if !sdl.Init({.VIDEO}) do os.exit(1) - window := sdl.CreateWindow("Hellope!", 500, 500, {.HIGH_PIXEL_DENSITY}) + window := sdl.CreateWindow("Hellope!", 600, 600, {.HIGH_PIXEL_DENSITY}) gpu := sdl.CreateGPUDevice({.MSL}, true, nil) if !sdl.ClaimWindowForGPUDevice(gpu, window) do os.exit(1) if !draw.init(gpu, window) do os.exit(1) JETBRAINS_MONO_REGULAR = draw.register_font(JETBRAINS_MONO_REGULAR_RAW) FONT_SIZE :: u16(24) - TEXT_ID :: u32(1) - - font := draw.get_font(JETBRAINS_MONO_REGULAR, FONT_SIZE) - dpi := sdl.GetWindowDisplayScale(window) + spin_angle: f32 = 0 for { defer free_all(context.temp_allocator) @@ -75,28 +124,74 @@ hellope_text :: proc() { for sdl.PollEvent(&ev) { if ev.type == .QUIT do return } - base_layer := draw.begin({w = 500, h = 500}) + spin_angle += 0.5 + base_layer := draw.begin({w = 600, h = 600}) // Grey background - draw.rectangle(base_layer, {0, 0, 500, 500}, {127, 127, 127, 255}) + draw.rectangle(base_layer, {0, 0, 600, 600}, {127, 127, 127, 255}) - // Measure and center text - tw, th: c.int - sdl_ttf.GetStringSize(font, "Hellope!", 0, &tw, &th) - text_w := f32(tw) / dpi - text_h := f32(th) / dpi - pos_x := (500.0 - text_w) / 2.0 - pos_y := (500.0 - text_h) / 2.0 + // ----- Text API demos ----- - txt := draw.text( - TEXT_ID, + // Cached text with id — TTF_Text reused across frames (good for text-heavy apps) + draw.text( + base_layer, "Hellope!", - {pos_x, pos_y}, + {300, 80}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, color = draw.WHITE, - font_id = JETBRAINS_MONO_REGULAR, - font_size = FONT_SIZE, + origin = draw.center_of("Hellope!", JETBRAINS_MONO_REGULAR, FONT_SIZE), + id = "hellope", + ) + + // Rotating sentence — verifies multi-word text rotation around center + draw.text( + base_layer, + "Hellope World!", + {300, 250}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = {255, 200, 50, 255}, + origin = draw.center_of("Hellope World!", JETBRAINS_MONO_REGULAR, FONT_SIZE), + rotation = spin_angle, + id = "rotating_sentence", + ) + + // Uncached text (no id) — created and destroyed each frame, simplest usage + draw.text( + base_layer, + "Top-left anchored", + {20, 450}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = draw.WHITE, + ) + + // Measure text for manual layout + size := draw.measure_text("Measured!", JETBRAINS_MONO_REGULAR, FONT_SIZE) + draw.rectangle(base_layer, {300 - size.x / 2, 380, size.x, size.y}, {60, 60, 60, 200}) + draw.text( + base_layer, + "Measured!", + {300, 380}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = draw.WHITE, + origin = draw.top_of("Measured!", JETBRAINS_MONO_REGULAR, FONT_SIZE), + id = "measured", + ) + + // Rotating text anchored at top-left (no origin offset) — spins around top-left corner + draw.text( + base_layer, + "Corner spin", + {150, 530}, + JETBRAINS_MONO_REGULAR, + FONT_SIZE, + color = {100, 200, 255, 255}, + rotation = spin_angle, + id = "corner_spin", ) - draw.prepare_text(base_layer, txt) draw.end(gpu, window) } diff --git a/draw/pipeline_2d_base.odin b/draw/pipeline_2d_base.odin index 2f95fe7..d17bc56 100644 --- a/draw/pipeline_2d_base.odin +++ b/draw/pipeline_2d_base.odin @@ -103,7 +103,8 @@ Primitive :: struct { bounds: [4]f32, // 0: min_x, min_y, max_x, max_y (world-space, pre-DPI) color: Color, // 16: u8x4, unpacked in shader via unpackUnorm4x8 kind_flags: u32, // 20: (kind as u32) | (flags as u32 << 8) - _pad: [2]f32, // 24: alignment to vec4 boundary + rotation: f32, // 24: shader self-rotation in radians (used by RRect, Ellipse) + _pad: f32, // 28: alignment to vec4 boundary params: Shape_Params, // 32: two vec4s of shape params } diff --git a/draw/shaders/generated/base_2d.frag.metal b/draw/shaders/generated/base_2d.frag.metal index 933919c..e03eb46 100644 --- a/draw/shaders/generated/base_2d.frag.metal +++ b/draw/shaders/generated/base_2d.frag.metal @@ -24,32 +24,41 @@ struct main0_in float4 f_params [[user(locn2)]]; float4 f_params2 [[user(locn3)]]; uint f_kind_flags [[user(locn4)]]; + float f_rotation [[user(locn5), flat]]; }; +static inline __attribute__((always_inline)) +float2 apply_rotation(thread const float2& p, thread const float& angle) +{ + float cr = cos(-angle); + float sr = sin(-angle); + return float2x2(float2(cr, sr), float2(-sr, cr)) * p; +} + static inline __attribute__((always_inline)) float sdRoundedBox(thread const float2& p, thread const float2& b, thread float4& r) { - float2 _56; + float2 _61; if (p.x > 0.0) { - _56 = r.xy; + _61 = r.xy; } else { - _56 = r.zw; + _61 = r.zw; } - r.x = _56.x; - r.y = _56.y; - float _73; + r.x = _61.x; + r.y = _61.y; + float _78; if (p.y > 0.0) { - _73 = r.x; + _78 = r.x; } else { - _73 = r.y; + _78 = r.y; } - r.x = _73; + r.x = _78; float2 q = (abs(p) - b) + float2(r.x); return (fast::min(fast::max(q.x, q.y), 0.0) + length(fast::max(q, float2(0.0)))) - r.x; } @@ -142,16 +151,23 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur float4 r = float4(in.f_params.zw, in.f_params2.xy); soft = fast::max(in.f_params2.z, 1.0); float stroke_px = in.f_params2.w; - float2 param = in.f_local_or_uv; - float2 param_1 = b; - float4 param_2 = r; - float _453 = sdRoundedBox(param, param_1, param_2); - d = _453; + float2 p_local = in.f_local_or_uv; + if (in.f_rotation != 0.0) + { + float2 param = p_local; + float param_1 = in.f_rotation; + p_local = apply_rotation(param, param_1); + } + float2 param_2 = p_local; + float2 param_3 = b; + float4 param_4 = r; + float _491 = sdRoundedBox(param_2, param_3, param_4); + d = _491; if ((flags & 1u) != 0u) { - float param_3 = d; - float param_4 = stroke_px; - d = sdf_stroke(param_3, param_4); + float param_5 = d; + float param_6 = stroke_px; + d = sdf_stroke(param_5, param_6); } } else @@ -161,14 +177,14 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur float radius = in.f_params.x; soft = fast::max(in.f_params.y, 1.0); float stroke_px_1 = in.f_params.z; - float2 param_5 = in.f_local_or_uv; - float param_6 = radius; - d = sdCircle(param_5, param_6); + float2 param_7 = in.f_local_or_uv; + float param_8 = radius; + d = sdCircle(param_7, param_8); if ((flags & 1u) != 0u) { - float param_7 = d; - float param_8 = stroke_px_1; - d = sdf_stroke(param_7, param_8); + float param_9 = d; + float param_10 = stroke_px_1; + d = sdf_stroke(param_9, param_10); } } else @@ -178,15 +194,22 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur float2 ab = in.f_params.xy; soft = fast::max(in.f_params.z, 1.0); float stroke_px_2 = in.f_params.w; - float2 param_9 = in.f_local_or_uv; - float2 param_10 = ab; - float _511 = sdEllipse(param_9, param_10); - d = _511; + float2 p_local_1 = in.f_local_or_uv; + if (in.f_rotation != 0.0) + { + float2 param_11 = p_local_1; + float param_12 = in.f_rotation; + p_local_1 = apply_rotation(param_11, param_12); + } + float2 param_13 = p_local_1; + float2 param_14 = ab; + float _560 = sdEllipse(param_13, param_14); + d = _560; if ((flags & 1u) != 0u) { - float param_11 = d; - float param_12 = stroke_px_2; - d = sdf_stroke(param_11, param_12); + float param_15 = d; + float param_16 = stroke_px_2; + d = sdf_stroke(param_15, param_16); } } else @@ -197,10 +220,10 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur float2 b_1 = in.f_params.zw; float width = in.f_params2.x; soft = fast::max(in.f_params2.y, 1.0); - float2 param_13 = in.f_local_or_uv; - float2 param_14 = a; - float2 param_15 = b_1; - d = sdSegment(param_13, param_14, param_15) - (width * 0.5); + float2 param_17 = in.f_local_or_uv; + float2 param_18 = a; + float2 param_19 = b_1; + d = sdSegment(param_17, param_18, param_19) - (width * 0.5); } else { @@ -218,26 +241,18 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur { angle += 6.283185482025146484375; } - float ang_start = start_rad; - float ang_end = end_rad; - if (ang_start < 0.0) - { - ang_start += 6.283185482025146484375; - } - if (ang_end < 0.0) - { - ang_end += 6.283185482025146484375; - } - float _615; + float ang_start = mod(start_rad, 6.283185482025146484375); + float ang_end = mod(end_rad, 6.283185482025146484375); + float _654; if (ang_end > ang_start) { - _615 = float((angle >= ang_start) && (angle <= ang_end)); + _654 = float((angle >= ang_start) && (angle <= ang_end)); } else { - _615 = float((angle >= ang_start) || (angle <= ang_end)); + _654 = float((angle >= ang_start) || (angle <= ang_end)); } - float in_arc = _615; + float in_arc = _654; if (abs(ang_end - ang_start) >= 6.282185077667236328125) { in_arc = 1.0; @@ -262,9 +277,9 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur d = (length(p) * cos(bn)) - radius_1; if ((flags & 1u) != 0u) { - float param_16 = d; - float param_17 = stroke_px_3; - d = sdf_stroke(param_16, param_17); + float param_20 = d; + float param_21 = stroke_px_3; + d = sdf_stroke(param_20, param_21); } } } @@ -272,9 +287,9 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d tex [[textur } } } - float param_18 = d; - float param_19 = soft; - float alpha = sdf_alpha(param_18, param_19); + float param_22 = d; + float param_23 = soft; + float alpha = sdf_alpha(param_22, param_23); out.out_color = float4(in.f_color.xyz, in.f_color.w * alpha); return out; } diff --git a/draw/shaders/generated/base_2d.frag.spv b/draw/shaders/generated/base_2d.frag.spv index a06c2ca..3917929 100644 Binary files a/draw/shaders/generated/base_2d.frag.spv and b/draw/shaders/generated/base_2d.frag.spv differ diff --git a/draw/shaders/generated/base_2d.vert.metal b/draw/shaders/generated/base_2d.vert.metal index a9bb3fe..b24ba01 100644 --- a/draw/shaders/generated/base_2d.vert.metal +++ b/draw/shaders/generated/base_2d.vert.metal @@ -15,7 +15,8 @@ struct Primitive float4 bounds; uint color; uint kind_flags; - float2 _pad; + float rotation; + float _pad; float4 params; float4 params2; }; @@ -25,7 +26,8 @@ struct Primitive_1 float4 bounds; uint color; uint kind_flags; - float2 _pad; + float rotation; + float _pad; float4 params; float4 params2; }; @@ -42,6 +44,7 @@ struct main0_out float4 f_params [[user(locn2)]]; float4 f_params2 [[user(locn3)]]; uint f_kind_flags [[user(locn4)]]; + float f_rotation [[user(locn5)]]; float4 gl_Position [[position]]; }; @@ -52,7 +55,7 @@ struct main0_in float4 v_color [[attribute(2)]]; }; -vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Primitives& _70 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]]) +vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Primitives& _72 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]]) { main0_out out = {}; if (_12.mode == 0u) @@ -62,17 +65,19 @@ vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer out.f_params = float4(0.0); out.f_params2 = float4(0.0); out.f_kind_flags = 0u; + out.f_rotation = 0.0; out.gl_Position = _12.projection * float4(in.v_position * _12.dpi_scale, 0.0, 1.0); } else { Primitive p; - p.bounds = _70.primitives[int(gl_InstanceIndex)].bounds; - p.color = _70.primitives[int(gl_InstanceIndex)].color; - p.kind_flags = _70.primitives[int(gl_InstanceIndex)].kind_flags; - p._pad = _70.primitives[int(gl_InstanceIndex)]._pad; - p.params = _70.primitives[int(gl_InstanceIndex)].params; - p.params2 = _70.primitives[int(gl_InstanceIndex)].params2; + p.bounds = _72.primitives[int(gl_InstanceIndex)].bounds; + p.color = _72.primitives[int(gl_InstanceIndex)].color; + p.kind_flags = _72.primitives[int(gl_InstanceIndex)].kind_flags; + p.rotation = _72.primitives[int(gl_InstanceIndex)].rotation; + p._pad = _72.primitives[int(gl_InstanceIndex)]._pad; + p.params = _72.primitives[int(gl_InstanceIndex)].params; + p.params2 = _72.primitives[int(gl_InstanceIndex)].params2; float2 corner = in.v_position; float2 world_pos = mix(p.bounds.xy, p.bounds.zw, corner); float2 center = (p.bounds.xy + p.bounds.zw) * 0.5; @@ -81,6 +86,7 @@ vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer out.f_params = p.params; out.f_params2 = p.params2; out.f_kind_flags = p.kind_flags; + out.f_rotation = p.rotation; out.gl_Position = _12.projection * float4(world_pos * _12.dpi_scale, 0.0, 1.0); } return out; diff --git a/draw/shaders/generated/base_2d.vert.spv b/draw/shaders/generated/base_2d.vert.spv index d32d2b8..c318fc2 100644 Binary files a/draw/shaders/generated/base_2d.vert.spv and b/draw/shaders/generated/base_2d.vert.spv differ diff --git a/draw/shaders/source/base_2d.frag b/draw/shaders/source/base_2d.frag index 013dfd1..e6af939 100644 --- a/draw/shaders/source/base_2d.frag +++ b/draw/shaders/source/base_2d.frag @@ -6,6 +6,7 @@ layout(location = 1) in vec2 f_local_or_uv; layout(location = 2) in vec4 f_params; layout(location = 3) in vec4 f_params2; layout(location = 4) flat in uint f_kind_flags; +layout(location = 5) flat in float f_rotation; // --- Output --- layout(location = 0) out vec4 out_color; @@ -82,6 +83,15 @@ float sdf_stroke(float d, float stroke_width) { return abs(d) - stroke_width * 0.5; } +// Rotate a 2D point by the negative of the given angle (inverse rotation). +// Used to rotate the sampling frame opposite to the shape's rotation so that +// the SDF evaluates correctly for the rotated shape. +vec2 apply_rotation(vec2 p, float angle) { + float cr = cos(-angle); + float sr = sin(-angle); + return mat2(cr, sr, -sr, cr) * p; +} + // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- @@ -113,11 +123,16 @@ void main() { soft = max(f_params2.z, 1.0); float stroke_px = f_params2.w; - d = sdRoundedBox(f_local_or_uv, b, r); + vec2 p_local = f_local_or_uv; + if (f_rotation != 0.0) { + p_local = apply_rotation(p_local, f_rotation); + } + + d = sdRoundedBox(p_local, b, r); if ((flags & 1u) != 0u) d = sdf_stroke(d, stroke_px); } else if (kind == 2u) { - // Circle + // Circle — rotationally symmetric, no rotation needed float radius = f_params.x; soft = max(f_params.y, 1.0); float stroke_px = f_params.z; @@ -131,11 +146,16 @@ void main() { soft = max(f_params.z, 1.0); float stroke_px = f_params.w; - d = sdEllipse(f_local_or_uv, ab); + vec2 p_local = f_local_or_uv; + if (f_rotation != 0.0) { + p_local = apply_rotation(p_local, f_rotation); + } + + d = sdEllipse(p_local, ab); if ((flags & 1u) != 0u) d = sdf_stroke(d, stroke_px); } else if (kind == 4u) { - // Segment (capsule line) + // Segment (capsule line) — no rotation (excluded) vec2 a = f_params.xy; // already in local physical pixels vec2 b = f_params.zw; float width = f_params2.x; @@ -144,7 +164,7 @@ void main() { d = sdSegment(f_local_or_uv, a, b) - width * 0.5; } else if (kind == 5u) { - // Ring / Arc + // Ring / Arc — rotation handled by CPU angle offset, no shader rotation float inner = f_params.x; float outer = f_params.y; float start_rad = f_params.z; @@ -157,10 +177,8 @@ void main() { // Angular clip float angle = atan(f_local_or_uv.y, f_local_or_uv.x); if (angle < 0.0) angle += 2.0 * PI; - float ang_start = start_rad; - float ang_end = end_rad; - if (ang_start < 0.0) ang_start += 2.0 * PI; - if (ang_end < 0.0) ang_end += 2.0 * PI; + float ang_start = mod(start_rad, 2.0 * PI); + float ang_end = mod(end_rad, 2.0 * PI); float in_arc = (ang_end > ang_start) ? ((angle >= ang_start && angle <= ang_end) ? 1.0 : 0.0) : ((angle >= ang_start || angle <= ang_end) ? 1.0 : 0.0); @@ -169,7 +187,7 @@ void main() { d = in_arc > 0.5 ? d_ring : 1e30; } else if (kind == 6u) { - // Regular N-gon + // Regular N-gon — has its own rotation in params, no Primitive.rotation used float radius = f_params.x; float rotation = f_params.y; float sides = f_params.z; diff --git a/draw/shaders/source/base_2d.vert b/draw/shaders/source/base_2d.vert index 2e09ec6..e72aa3b 100644 --- a/draw/shaders/source/base_2d.vert +++ b/draw/shaders/source/base_2d.vert @@ -11,6 +11,7 @@ layout(location = 1) out vec2 f_local_or_uv; layout(location = 2) out vec4 f_params; layout(location = 3) out vec4 f_params2; layout(location = 4) flat out uint f_kind_flags; +layout(location = 5) flat out float f_rotation; // ---------- Uniforms (single block — avoids spirv-cross reordering on Metal) ---------- layout(set = 1, binding = 0) uniform Uniforms { @@ -24,7 +25,8 @@ struct Primitive { vec4 bounds; // 0-15: min_x, min_y, max_x, max_y uint color; // 16-19: packed u8x4 (unpack with unpackUnorm4x8) uint kind_flags; // 20-23: kind | (flags << 8) - vec2 _pad; // 24-31: padding + float rotation; // 24-27: shader self-rotation in radians + float _pad; // 28-31: alignment padding vec4 params; // 32-47: shape params part 1 vec4 params2; // 48-63: shape params part 2 }; @@ -42,6 +44,7 @@ void main() { f_params = vec4(0.0); f_params2 = vec4(0.0); f_kind_flags = 0u; + f_rotation = 0.0; gl_Position = projection * vec4(v_position * dpi_scale, 0.0, 1.0); } else { @@ -57,6 +60,7 @@ void main() { f_params = p.params; f_params2 = p.params2; f_kind_flags = p.kind_flags; + f_rotation = p.rotation; gl_Position = projection * vec4(world_pos * dpi_scale, 0.0, 1.0); } diff --git a/draw/shapes.odin b/draw/shapes.odin index 0d75d74..ade129a 100644 --- a/draw/shapes.odin +++ b/draw/shapes.odin @@ -76,90 +76,6 @@ pixel :: proc(layer: ^Layer, pos: [2]f32, color: Color) { prepare_shape(layer, vertices[:]) } -rectangle :: proc( - layer: ^Layer, - rect: Rectangle, - color: Color, - origin: [2]f32 = {0, 0}, - rotation: f32 = 0, - temp_allocator := context.temp_allocator, -) { - vertices := make([]Vertex, 6, temp_allocator) - - if rotation == 0 { - emit_rect(rect.x, rect.y, rect.w, rect.h, color, vertices, 0) - } else { - rad := math.to_radians(rotation) - cos_rotation := math.cos(rad) - sin_rotation := math.sin(rad) - - // Corners relative to origin - top_left := [2]f32{-origin[0], -origin[1]} - top_right := [2]f32{rect.w - origin[0], -origin[1]} - bottom_right := [2]f32{rect.w - origin[0], rect.h - origin[1]} - bottom_left := [2]f32{-origin[0], rect.h - origin[1]} - - // Translation to final position - translate := [2]f32{rect.x + origin[0], rect.y + origin[1]} - - // Rotate and translate each corner - tl := - [2]f32 { - cos_rotation * top_left[0] - sin_rotation * top_left[1], - sin_rotation * top_left[0] + cos_rotation * top_left[1], - } + - translate - tr := - [2]f32 { - cos_rotation * top_right[0] - sin_rotation * top_right[1], - sin_rotation * top_right[0] + cos_rotation * top_right[1], - } + - translate - br := - [2]f32 { - cos_rotation * bottom_right[0] - sin_rotation * bottom_right[1], - sin_rotation * bottom_right[0] + cos_rotation * bottom_right[1], - } + - translate - bl := - [2]f32 { - cos_rotation * bottom_left[0] - sin_rotation * bottom_left[1], - sin_rotation * bottom_left[0] + cos_rotation * bottom_left[1], - } + - translate - - vertices[0] = sv(tl, color) - vertices[1] = sv(tr, color) - vertices[2] = sv(br, color) - vertices[3] = sv(tl, color) - vertices[4] = sv(br, color) - vertices[5] = sv(bl, color) - } - - prepare_shape(layer, vertices) -} - -rectangle_lines :: proc( - layer: ^Layer, - rect: Rectangle, - color: Color, - thick: f32 = 1, - temp_allocator := context.temp_allocator, -) { - vertices := make([]Vertex, 24, temp_allocator) - - // Top edge - emit_rect(rect.x, rect.y, rect.w, thick, color, vertices, 0) - // Bottom edge - emit_rect(rect.x, rect.y + rect.h - thick, rect.w, thick, color, vertices, 6) - // Left edge - emit_rect(rect.x, rect.y + thick, thick, rect.h - thick * 2, color, vertices, 12) - // Right edge - emit_rect(rect.x + rect.w - thick, rect.y + thick, thick, rect.h - thick * 2, color, vertices, 18) - - prepare_shape(layer, vertices) -} - rectangle_gradient :: proc( layer: ^Layer, rect: Rectangle, @@ -189,6 +105,8 @@ circle_sector :: proc( radius: f32, start_angle, end_angle: f32, color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, segments: int = 0, temp_allocator := context.temp_allocator, ) { @@ -202,17 +120,34 @@ circle_sector :: proc( end_rad := math.to_radians(end_angle) step_angle := (end_rad - start_rad) / f32(segs) - for i in 0 ..< segs { - current_angle := start_rad + step_angle * f32(i) - next_angle := start_rad + step_angle * f32(i + 1) + if !needs_transform(origin, rotation) { + for i in 0 ..< segs { + current_angle := start_rad + step_angle * f32(i) + next_angle := start_rad + step_angle * f32(i + 1) - edge_current := center + [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} - edge_next := center + [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} + edge_current := center + [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} + edge_next := center + [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} - idx := i * 3 - vertices[idx + 0] = sv(center, color) - vertices[idx + 1] = sv(edge_next, color) - vertices[idx + 2] = sv(edge_current, color) + idx := i * 3 + vertices[idx + 0] = sv(center, color) + vertices[idx + 1] = sv(edge_next, color) + vertices[idx + 2] = sv(edge_current, color) + } + } else { + xform := build_pivot_rot(center, origin, rotation) + center_local := [2]f32{0, 0} + for i in 0 ..< segs { + current_angle := start_rad + step_angle * f32(i) + next_angle := start_rad + step_angle * f32(i + 1) + + edge_current := [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} + edge_next := [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} + + idx := i * 3 + vertices[idx + 0] = sv(apply_transform(xform, center_local), color) + vertices[idx + 1] = sv(apply_transform(xform, edge_next), color) + vertices[idx + 2] = sv(apply_transform(xform, edge_current), color) + } } prepare_shape(layer, vertices) @@ -223,6 +158,8 @@ circle_gradient :: proc( center: [2]f32, radius: f32, inner, outer: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, segments: int = 0, temp_allocator := context.temp_allocator, ) { @@ -233,24 +170,61 @@ circle_gradient :: proc( step_angle := math.TAU / f32(segs) - for i in 0 ..< segs { - current_angle := step_angle * f32(i) - next_angle := step_angle * f32(i + 1) + if !needs_transform(origin, rotation) { + for i in 0 ..< segs { + current_angle := step_angle * f32(i) + next_angle := step_angle * f32(i + 1) - edge_current := center + [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} - edge_next := center + [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} + edge_current := center + [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} + edge_next := center + [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} - idx := i * 3 - vertices[idx + 0] = sv(center, inner) - vertices[idx + 1] = sv(edge_next, outer) - vertices[idx + 2] = sv(edge_current, outer) + idx := i * 3 + vertices[idx + 0] = sv(center, inner) + vertices[idx + 1] = sv(edge_next, outer) + vertices[idx + 2] = sv(edge_current, outer) + } + } else { + xform := build_pivot_rot(center, origin, rotation) + center_local := [2]f32{0, 0} + for i in 0 ..< segs { + current_angle := step_angle * f32(i) + next_angle := step_angle * f32(i + 1) + + edge_current := [2]f32{math.cos(current_angle) * radius, math.sin(current_angle) * radius} + edge_next := [2]f32{math.cos(next_angle) * radius, math.sin(next_angle) * radius} + + idx := i * 3 + vertices[idx + 0] = sv(apply_transform(xform, center_local), inner) + vertices[idx + 1] = sv(apply_transform(xform, edge_next), outer) + vertices[idx + 2] = sv(apply_transform(xform, edge_current), outer) + } } prepare_shape(layer, vertices) } -triangle :: proc(layer: ^Layer, v1, v2, v3: [2]f32, color: Color) { - vertices := [3]Vertex{sv(v1, color), sv(v2, color), sv(v3, color)} +triangle :: proc( + layer: ^Layer, + v1, v2, v3: [2]f32, + color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, +) { + if !needs_transform(origin, rotation) { + vertices := [3]Vertex{sv(v1, color), sv(v2, color), sv(v3, color)} + prepare_shape(layer, vertices[:]) + return + } + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + xform := build_pivot_rot(mn, origin, rotation) + local_v1 := v1 - mn + local_v2 := v2 - mn + local_v3 := v3 - mn + vertices := [3]Vertex { + sv(apply_transform(xform, local_v1), color), + sv(apply_transform(xform, local_v2), color), + sv(apply_transform(xform, local_v3), color), + } prepare_shape(layer, vertices[:]) } @@ -259,13 +233,28 @@ triangle_lines :: proc( v1, v2, v3: [2]f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, temp_allocator := context.temp_allocator, ) { vertices := make([]Vertex, 18, temp_allocator) write_offset := 0 - write_offset += extrude_line(v1, v2, thick, color, vertices, write_offset) - write_offset += extrude_line(v2, v3, thick, color, vertices, write_offset) - write_offset += extrude_line(v3, v1, thick, color, vertices, write_offset) + + if !needs_transform(origin, rotation) { + write_offset += extrude_line(v1, v2, thick, color, vertices, write_offset) + write_offset += extrude_line(v2, v3, thick, color, vertices, write_offset) + write_offset += extrude_line(v3, v1, thick, color, vertices, write_offset) + } else { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + xform := build_pivot_rot(mn, origin, rotation) + tv1 := apply_transform(xform, v1 - mn) + tv2 := apply_transform(xform, v2 - mn) + tv3 := apply_transform(xform, v3 - mn) + write_offset += extrude_line(tv1, tv2, thick, color, vertices, write_offset) + write_offset += extrude_line(tv2, tv3, thick, color, vertices, write_offset) + write_offset += extrude_line(tv3, tv1, thick, color, vertices, write_offset) + } + if write_offset > 0 { prepare_shape(layer, vertices[:write_offset]) } @@ -275,6 +264,8 @@ triangle_fan :: proc( layer: ^Layer, points: [][2]f32, color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, temp_allocator := context.temp_allocator, ) { if len(points) < 3 do return @@ -283,11 +274,26 @@ triangle_fan :: proc( vertex_count := triangle_count * 3 vertices := make([]Vertex, vertex_count, temp_allocator) - for i in 1 ..< len(points) - 1 { - idx := (i - 1) * 3 - vertices[idx + 0] = sv(points[0], color) - vertices[idx + 1] = sv(points[i], color) - vertices[idx + 2] = sv(points[i + 1], color) + if !needs_transform(origin, rotation) { + for i in 1 ..< len(points) - 1 { + idx := (i - 1) * 3 + vertices[idx + 0] = sv(points[0], color) + vertices[idx + 1] = sv(points[i], color) + vertices[idx + 2] = sv(points[i + 1], color) + } + } else { + mn := [2]f32{max(f32), max(f32)} + for p in points { + mn.x = min(mn.x, p.x) + mn.y = min(mn.y, p.y) + } + xform := build_pivot_rot(mn, origin, rotation) + for i in 1 ..< len(points) - 1 { + idx := (i - 1) * 3 + vertices[idx + 0] = sv(apply_transform(xform, points[0] - mn), color) + vertices[idx + 1] = sv(apply_transform(xform, points[i] - mn), color) + vertices[idx + 2] = sv(apply_transform(xform, points[i + 1] - mn), color) + } } prepare_shape(layer, vertices) @@ -297,6 +303,8 @@ triangle_strip :: proc( layer: ^Layer, points: [][2]f32, color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, temp_allocator := context.temp_allocator, ) { if len(points) < 3 do return @@ -305,16 +313,37 @@ triangle_strip :: proc( vertex_count := triangle_count * 3 vertices := make([]Vertex, vertex_count, temp_allocator) - for i in 0 ..< triangle_count { - idx := i * 3 - if i % 2 == 0 { - vertices[idx + 0] = sv(points[i], color) - vertices[idx + 1] = sv(points[i + 1], color) - vertices[idx + 2] = sv(points[i + 2], color) - } else { - vertices[idx + 0] = sv(points[i + 1], color) - vertices[idx + 1] = sv(points[i], color) - vertices[idx + 2] = sv(points[i + 2], color) + if !needs_transform(origin, rotation) { + for i in 0 ..< triangle_count { + idx := i * 3 + if i % 2 == 0 { + vertices[idx + 0] = sv(points[i], color) + vertices[idx + 1] = sv(points[i + 1], color) + vertices[idx + 2] = sv(points[i + 2], color) + } else { + vertices[idx + 0] = sv(points[i + 1], color) + vertices[idx + 1] = sv(points[i], color) + vertices[idx + 2] = sv(points[i + 2], color) + } + } + } else { + mn := [2]f32{max(f32), max(f32)} + for p in points { + mn.x = min(mn.x, p.x) + mn.y = min(mn.y, p.y) + } + xform := build_pivot_rot(mn, origin, rotation) + for i in 0 ..< triangle_count { + idx := i * 3 + if i % 2 == 0 { + vertices[idx + 0] = sv(apply_transform(xform, points[i] - mn), color) + vertices[idx + 1] = sv(apply_transform(xform, points[i + 1] - mn), color) + vertices[idx + 2] = sv(apply_transform(xform, points[i + 2] - mn), color) + } else { + vertices[idx + 0] = sv(apply_transform(xform, points[i + 1] - mn), color) + vertices[idx + 1] = sv(apply_transform(xform, points[i] - mn), color) + vertices[idx + 2] = sv(apply_transform(xform, points[i + 2] - mn), color) + } } } @@ -323,8 +352,68 @@ triangle_strip :: proc( // ----- SDF drawing functions ---- +// Compute new center position after rotating a center-parametrized shape +// around a pivot point. The pivot is at (center + origin) in world space. +@(private = "file") +compute_pivot_center :: proc(center: [2]f32, origin: [2]f32, rotation_deg: f32) -> [2]f32 { + if origin == {0, 0} do return center + theta := math.to_radians(rotation_deg) + c, s := math.cos(theta), math.sin(theta) + // pivot = center + origin; new_center = pivot + R(θ) * (center - pivot) + return center + origin + {c * (-origin.x) - s * (-origin.y), s * (-origin.x) + c * (-origin.y)} +} + +// Compute the AABB half-extents of a rectangle with half-size (hx, hy) rotated by rot_rad. +@(private = "file") +rotated_aabb_half :: proc(hx, hy, rot_rad: f32) -> [2]f32 { + c_r := abs(math.cos(rot_rad)) + s_r := abs(math.sin(rot_rad)) + return {hx * c_r + hy * s_r, hx * s_r + hy * c_r} +} + +// Draw a filled rectangle via SDF (analytical anti-aliasing at all orientations). +// `roundness` is a 0–1 fraction controlling uniform corner rounding — 0 is sharp, 1 is fully rounded. +// For per-corner pixel-precise rounding, use `rectangle_corners` instead. +rectangle :: proc( + layer: ^Layer, + rect: Rectangle, + color: Color, + roundness: f32 = 0, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { + cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 + rectangle_corners(layer, rect, {cr, cr, cr, cr}, color, origin, rotation, soft_px) +} + +// Draw a stroked rectangle via SDF (analytical anti-aliasing at all orientations). +// `roundness` is a 0–1 fraction controlling uniform corner rounding — 0 is sharp, 1 is fully rounded. +// For per-corner pixel-precise rounding, use `rectangle_corners_lines` instead. +rectangle_lines :: proc( + layer: ^Layer, + rect: Rectangle, + color: Color, + thick: f32 = 1, + roundness: f32 = 0, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { + cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 + rectangle_corners_lines(layer, rect, {cr, cr, cr, cr}, color, thick, origin, rotation, soft_px) +} + // Draw a rectangle with per-corner rounding radii via SDF. -rectangle_corners :: proc(layer: ^Layer, rect: Rectangle, radii: [4]f32, color: Color, soft_px: f32 = 1.0) { +rectangle_corners :: proc( + layer: ^Layer, + rect: Rectangle, + radii: [4]f32, + color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { max_radius := min(rect.w, rect.h) * 0.5 tl := clamp(radii[0], 0, max_radius) tr := clamp(radii[1], 0, max_radius) @@ -334,13 +423,35 @@ rectangle_corners :: proc(layer: ^Layer, rect: Rectangle, radii: [4]f32, color: pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + hx := rect.w * 0.5 + hy := rect.h * 0.5 + rot_rad: f32 = 0 + center_x := rect.x + hx + center_y := rect.y + hy + + if needs_transform(origin, rotation) { + rot_rad = math.to_radians(rotation) + xform := build_pivot_rot({rect.x, rect.y}, origin, rotation) + new_center := apply_transform(xform, {hx, hy}) + center_x = new_center.x + center_y = new_center.y + } + + bhx, bhy := hx, hy + if rot_rad != 0 { + expanded := rotated_aabb_half(hx, hy, rot_rad) + bhx = expanded.x + bhy = expanded.y + } + prim := Primitive { - bounds = {rect.x - pad, rect.y - pad, rect.x + rect.w + pad, rect.y + rect.h + pad}, + bounds = {center_x - bhx - pad, center_y - bhy - pad, center_x + bhx + pad, center_y + bhy + pad}, color = color, kind_flags = pack_kind_flags(.RRect, {}), + rotation = rot_rad, } prim.params.rrect = RRect_Params { - half_size = {rect.w * 0.5 * dpi, rect.h * 0.5 * dpi}, + half_size = {hx * dpi, hy * dpi}, radii = {tr * dpi, br * dpi, tl * dpi, bl * dpi}, soft_px = soft_px, stroke_px = 0, @@ -355,6 +466,8 @@ rectangle_corners_lines :: proc( radii: [4]f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { max_radius := min(rect.w, rect.h) * 0.5 @@ -366,13 +479,35 @@ rectangle_corners_lines :: proc( pad := (thick * 0.5 + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + hx := rect.w * 0.5 + hy := rect.h * 0.5 + rot_rad: f32 = 0 + center_x := rect.x + hx + center_y := rect.y + hy + + if needs_transform(origin, rotation) { + rot_rad = math.to_radians(rotation) + xform := build_pivot_rot({rect.x, rect.y}, origin, rotation) + new_center := apply_transform(xform, {hx, hy}) + center_x = new_center.x + center_y = new_center.y + } + + bhx, bhy := hx, hy + if rot_rad != 0 { + expanded := rotated_aabb_half(hx, hy, rot_rad) + bhx = expanded.x + bhy = expanded.y + } + prim := Primitive { - bounds = {rect.x - pad, rect.y - pad, rect.x + rect.w + pad, rect.y + rect.h + pad}, + bounds = {center_x - bhx - pad, center_y - bhy - pad, center_x + bhx + pad, center_y + bhy + pad}, color = color, kind_flags = pack_kind_flags(.RRect, {.Stroke}), + rotation = rot_rad, } prim.params.rrect = RRect_Params { - half_size = {rect.w * 0.5 * dpi, rect.h * 0.5 * dpi}, + half_size = {hx * dpi, hy * dpi}, radii = {tr * dpi, br * dpi, tl * dpi, bl * dpi}, soft_px = soft_px, stroke_px = thick * dpi, @@ -380,47 +515,34 @@ rectangle_corners_lines :: proc( prepare_sdf_primitive(layer, prim) } -// Draw a rectangle with uniform corner rounding via SDF. -rectangle_rounded :: proc(layer: ^Layer, rect: Rectangle, roundness: f32, color: Color, soft_px: f32 = 1.0) { - cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 - if cr < 1 { - rectangle(layer, rect, color) - return - } - rectangle_corners(layer, rect, {cr, cr, cr, cr}, color, soft_px) -} - -// Draw a stroked rectangle with uniform corner rounding via SDF. -rectangle_rounded_lines :: proc( +// Draw a filled circle via SDF. +circle :: proc( layer: ^Layer, - rect: Rectangle, - roundness: f32, + center: [2]f32, + radius: f32, color: Color, - thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { - cr := min(rect.w, rect.h) * clamp(roundness, 0, 1) * 0.5 - if cr < 1 { - rectangle_lines(layer, rect, color, thick) - return - } - rectangle_corners_lines(layer, rect, {cr, cr, cr, cr}, color, thick, soft_px) -} - -// Draw a filled circle via SDF. -circle :: proc(layer: ^Layer, center: [2]f32, radius: f32, color: Color, soft_px: f32 = 1.0) { pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.Circle, {}), + // rotation stays 0 — circle is rotationally symmetric } prim.params.circle = Circle_Params { radius = radius * dpi, @@ -436,17 +558,24 @@ circle_lines :: proc( radius: f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { pad := (thick * 0.5 + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.Circle, {.Stroke}), @@ -460,19 +589,43 @@ circle_lines :: proc( } // Draw a filled ellipse via SDF. -ellipse :: proc(layer: ^Layer, center: [2]f32, radius_h, radius_v: f32, color: Color, soft_px: f32 = 1.0) { +ellipse :: proc( + layer: ^Layer, + center: [2]f32, + radius_h, radius_v: f32, + color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + soft_px: f32 = 1.0, +) { pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + rot_rad: f32 = 0 + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + rot_rad = math.to_radians(rotation) + } + + // When rotated, expand the bounds AABB to enclose the rotated ellipse + bound_h, bound_v := radius_h, radius_v + if rot_rad != 0 { + expanded := rotated_aabb_half(radius_h, radius_v, rot_rad) + bound_h = expanded.x + bound_v = expanded.y + } + prim := Primitive { bounds = { - center.x - radius_h - pad, - center.y - radius_v - pad, - center.x + radius_h + pad, - center.y + radius_v + pad, + actual_center.x - bound_h - pad, + actual_center.y - bound_v - pad, + actual_center.x + bound_h + pad, + actual_center.y + bound_v + pad, }, color = color, kind_flags = pack_kind_flags(.Ellipse, {}), + rotation = rot_rad, } prim.params.ellipse = Ellipse_Params { radii = {radius_h * dpi, radius_v * dpi}, @@ -488,22 +641,40 @@ ellipse_lines :: proc( radius_h, radius_v: f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { // Extra 10% padding: iq's sdEllipse has precision degradation near the tips of highly // eccentric ellipses, so the quad needs additional breathing room beyond the stroke width. - pad := (max(radius_h, radius_v) * 0.1 + thick * 0.5 + soft_px) / GLOB.dpi_scaling + extra := max(radius_h, radius_v) * 0.1 + thick * 0.5 + pad := (extra + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + rot_rad: f32 = 0 + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + rot_rad = math.to_radians(rotation) + } + + bound_h, bound_v := radius_h, radius_v + if rot_rad != 0 { + expanded := rotated_aabb_half(radius_h, radius_v, rot_rad) + bound_h = expanded.x + bound_v = expanded.y + } + prim := Primitive { bounds = { - center.x - radius_h - pad, - center.y - radius_v - pad, - center.x + radius_h + pad, - center.y + radius_v + pad, + actual_center.x - bound_h - pad, + actual_center.y - bound_v - pad, + actual_center.x + bound_h + pad, + actual_center.y + bound_v + pad, }, color = color, kind_flags = pack_kind_flags(.Ellipse, {.Stroke}), + rotation = rot_rad, } prim.params.ellipse = Ellipse_Params { radii = {radius_h * dpi, radius_v * dpi}, @@ -520,26 +691,36 @@ ring :: proc( inner_radius, outer_radius: f32, start_angle, end_angle: f32, color: Color, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + rotation_offset: f32 = 0 + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + rotation_offset = math.to_radians(rotation) + } + prim := Primitive { bounds = { - center.x - outer_radius - pad, - center.y - outer_radius - pad, - center.x + outer_radius + pad, - center.y + outer_radius + pad, + actual_center.x - outer_radius - pad, + actual_center.y - outer_radius - pad, + actual_center.x + outer_radius + pad, + actual_center.y + outer_radius + pad, }, color = color, kind_flags = pack_kind_flags(.Ring_Arc, {}), + // No shader rotation — arc rotation handled by offsetting start/end angles } prim.params.ring_arc = Ring_Arc_Params { inner_radius = inner_radius * dpi, outer_radius = outer_radius * dpi, - start_rad = math.to_radians(start_angle), - end_rad = math.to_radians(end_angle), + start_rad = math.to_radians(start_angle) + rotation_offset, + end_rad = math.to_radians(end_angle) + rotation_offset, soft_px = soft_px, } prepare_sdf_primitive(layer, prim) @@ -553,39 +734,50 @@ ring_lines :: proc( start_angle, end_angle: f32, color: Color, thick: f32 = 1, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, soft_px: f32 = 1.0, ) { - // Inner arc outline + // Compute effective angles and pivot-translated center up front + eff_start := start_angle + rotation + eff_end := end_angle + rotation + + actual_center := center + if needs_transform(origin, rotation) { + actual_center = compute_pivot_center(center, origin, rotation) + } + + // Inner arc outline (pass already-transformed center; no further origin/rotation) ring( layer, - center, + actual_center, max(0, inner_radius - thick * 0.5), inner_radius + thick * 0.5, - start_angle, - end_angle, + eff_start, + eff_end, color, - soft_px, + soft_px = soft_px, ) // Outer arc outline ring( layer, - center, + actual_center, max(0, outer_radius - thick * 0.5), outer_radius + thick * 0.5, - start_angle, - end_angle, + eff_start, + eff_end, color, - soft_px, + soft_px = soft_px, ) // Start cap - start_rad := math.to_radians(start_angle) - end_rad := math.to_radians(end_angle) - inner_start := center + {math.cos(start_rad) * inner_radius, math.sin(start_rad) * inner_radius} - outer_start := center + {math.cos(start_rad) * outer_radius, math.sin(start_rad) * outer_radius} + start_rad := math.to_radians(eff_start) + end_rad := math.to_radians(eff_end) + inner_start := actual_center + {math.cos(start_rad) * inner_radius, math.sin(start_rad) * inner_radius} + outer_start := actual_center + {math.cos(start_rad) * outer_radius, math.sin(start_rad) * outer_radius} line(layer, inner_start, outer_start, color, thick, soft_px) // End cap - inner_end := center + {math.cos(end_rad) * inner_radius, math.sin(end_rad) * inner_radius} - outer_end := center + {math.cos(end_rad) * outer_radius, math.sin(end_rad) * outer_radius} + inner_end := actual_center + {math.cos(end_rad) * inner_radius, math.sin(end_rad) * inner_radius} + outer_end := actual_center + {math.cos(end_rad) * outer_radius, math.sin(end_rad) * outer_radius} line(layer, inner_end, outer_end, color, thick, soft_px) } @@ -632,18 +824,24 @@ poly :: proc( radius: f32, color: Color, rotation: f32 = 0, + origin: [2]f32 = {0, 0}, soft_px: f32 = 1.0, ) { if sides < 3 do return pad := soft_px / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} && rotation != 0 { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.NGon, {}), @@ -665,6 +863,7 @@ poly_lines :: proc( radius: f32, color: Color, rotation: f32 = 0, + origin: [2]f32 = {0, 0}, thick: f32 = 1, soft_px: f32 = 1.0, ) { @@ -672,12 +871,17 @@ poly_lines :: proc( pad := (thick * 0.5 + soft_px) / GLOB.dpi_scaling dpi := GLOB.dpi_scaling + actual_center := center + if origin != {0, 0} && rotation != 0 { + actual_center = compute_pivot_center(center, origin, rotation) + } + prim := Primitive { bounds = { - center.x - radius - pad, - center.y - radius - pad, - center.x + radius + pad, - center.y + radius + pad, + actual_center.x - radius - pad, + actual_center.y - radius - pad, + actual_center.x + radius + pad, + actual_center.y + radius + pad, }, color = color, kind_flags = pack_kind_flags(.NGon, {.Stroke}), @@ -691,3 +895,103 @@ poly_lines :: proc( } prepare_sdf_primitive(layer, prim) } + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Anchor helpers ---------------- +// --------------------------------------------------------------------------------------------------------------------- +// +// Return [2]f32 pixel offsets for use as the `origin` parameter of draw calls. +// Composable with normal vector +/- arithmetic. +// +// Text anchor helpers are in text.odin (they depend on measure_text / SDL_ttf). + +// ----- Rectangle anchors (origin measured from rect's top-left) -------------------------------------------------- + +center_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w * 0.5, r.h * 0.5} +} + +top_left_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {0, 0} +} + +top_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w * 0.5, 0} +} + +top_right_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w, 0} +} + +left_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {0, r.h * 0.5} +} + +right_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w, r.h * 0.5} +} + +bottom_left_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {0, r.h} +} + +bottom_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w * 0.5, r.h} +} + +bottom_right_of_rect :: #force_inline proc(r: Rectangle) -> [2]f32 { + return {r.w, r.h} +} + +// ----- Triangle anchors (origin measured from AABB top-left) ----------------------------------------------------- + +center_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + return (v1 + v2 + v3) / 3 - mn +} + +top_left_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + return {0, 0} +} + +top_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_x := min(v1.x, v2.x, v3.x) + mx_x := max(v1.x, v2.x, v3.x) + return {(mx_x - mn_x) * 0.5, 0} +} + +top_right_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_x := min(v1.x, v2.x, v3.x) + mx_x := max(v1.x, v2.x, v3.x) + return {mx_x - mn_x, 0} +} + +left_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_y := min(v1.y, v2.y, v3.y) + mx_y := max(v1.y, v2.y, v3.y) + return {0, (mx_y - mn_y) * 0.5} +} + +right_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + mx := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} + return {mx.x - mn.x, (mx.y - mn.y) * 0.5} +} + +bottom_left_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn_y := min(v1.y, v2.y, v3.y) + mx_y := max(v1.y, v2.y, v3.y) + return {0, mx_y - mn_y} +} + +bottom_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + mx := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} + return {(mx.x - mn.x) * 0.5, mx.y - mn.y} +} + +bottom_right_of_triangle :: #force_inline proc(v1, v2, v3: [2]f32) -> [2]f32 { + mn := [2]f32{min(v1.x, v2.x, v3.x), min(v1.y, v2.y, v3.y)} + mx := [2]f32{max(v1.x, v2.x, v3.x), max(v1.y, v2.y, v3.y)} + return mx - mn +} diff --git a/draw/text.odin b/draw/text.odin index 4c04bda..742053b 100644 --- a/draw/text.odin +++ b/draw/text.odin @@ -1,6 +1,9 @@ package draw +import "core:c" +import "core:hash" import "core:log" +import "core:strings" import sdl "vendor:sdl3" import sdl_ttf "vendor:sdl3/ttf" @@ -71,32 +74,195 @@ Text :: struct { color: Color, } -text :: proc( - id: u32, - txt: cstring, - pos: [2]f32, - font_id: Font_Id, - font_size: u16 = 44, - color: Color = {0, 0, 0, 255}, -) -> Text { - sdl_text := GLOB.text_cache.cache[id] - if sdl_text == nil { - sdl_text = sdl_ttf.CreateText(GLOB.text_cache.engine, get_font(font_id, font_size), txt, 0) +// --------------------------------------------------------------------------------------------------------------------- +// ----- Text cache hashing ------------ +// --------------------------------------------------------------------------------------------------------------------- + +// Hash a string to a u32 cache key using the same Jenkins one-at-a-time algorithm as Clay. +// This means Clay element IDs and user-chosen string IDs share the same keyspace — same +// string produces the same cache key regardless of whether it came from Clay or user code. +text_cache_hash :: #force_inline proc(s: string) -> u32 { + return hash.jenkins(transmute([]u8)s) + 1 // +1 reserves 0 as "no entry" (matches Clay convention) +} + +// Shared cache lookup/create/update logic used by both the `text` proc and the Clay render path. +// Returns the cached (or newly created) TTF_Text pointer. +@(private) +cache_get_or_update :: proc(cache_id: u32, c_str: cstring, font: ^sdl_ttf.Font) -> ^sdl_ttf.Text { + existing, found := GLOB.text_cache.cache[cache_id] + if !found { + sdl_text := sdl_ttf.CreateText(GLOB.text_cache.engine, font, c_str, 0) if sdl_text == nil { log.panicf("Failed to create SDL text: %s", sdl.GetError()) } - GLOB.text_cache.cache[id] = sdl_text + GLOB.text_cache.cache[cache_id] = sdl_text + return sdl_text } else { - //TODO if IDs are always unique and never change the underlying text - // can get rid of this - if !sdl_ttf.SetTextString(sdl_text, txt, 0) { + if !sdl_ttf.SetTextString(existing, c_str, 0) { log.panicf("Failed to update SDL text string: %s", sdl.GetError()) } + return existing + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Text drawing ------------------ +// --------------------------------------------------------------------------------------------------------------------- + +// Draw text at a position with optional rotation and origin. +// +// When `id` is nil (the default), the text is created and destroyed each frame — simple and +// leak-free, appropriate for HUDs and moderate UI (up to ~50 text elements per frame). +// +// When `id` is set, the TTF_Text object is cached across frames using a hash of the provided +// string (same algorithm as Clay's element IDs). This avoids per-frame HarfBuzz shaping and +// allocation, which matters for text-heavy apps (editors, terminals, chat). The user is +// responsible for choosing unique ID strings per logical text element and calling +// `clear_text_cache` or `clear_text_cache_entry` when cached entries are no longer needed. +// +// `origin` is in pixels from the text block's top-left corner (raylib convention). +// The point whose local coords equal `origin` lands at `pos` in world space. +// `rotation` is in degrees, counter-clockwise. +text :: proc( + layer: ^Layer, + str: string, + pos: [2]f32, + font_id: Font_Id, + font_size: u16 = 44, + color: Color = BLACK, + origin: [2]f32 = {0, 0}, + rotation: f32 = 0, + id: Maybe(string) = nil, + temp_allocator := context.temp_allocator, +) { + c_str := strings.clone_to_cstring(str, temp_allocator) + + sdl_text: ^sdl_ttf.Text + cached := false + + if id_str, ok := id.?; ok { + cached = true + sdl_text = cache_get_or_update(text_cache_hash(id_str), c_str, get_font(font_id, font_size)) + } else { + sdl_text = sdl_ttf.CreateText(GLOB.text_cache.engine, get_font(font_id, font_size), c_str, 0) + if sdl_text == nil { + log.panicf("Failed to create SDL text: %s", sdl.GetError()) + } } - return Text{sdl_text, pos, color} + if needs_transform(origin, rotation) { + dpi := GLOB.dpi_scaling + xform := build_pivot_rot(pos * dpi, origin * dpi, rotation) + prepare_text_transformed(layer, Text{sdl_text, {0, 0}, color}, xform) + } else { + prepare_text(layer, Text{sdl_text, pos, color}) + } + + if !cached { + // Don't destroy now — the draw data (atlas texture, vertices) is still referenced + // by the batch buffers until end() submits to the GPU. Deferred to clear_global(). + append(&GLOB.tmp_uncached_text, sdl_text) + } } +// --------------------------------------------------------------------------------------------------------------------- +// ----- Public text measurement ------- +// --------------------------------------------------------------------------------------------------------------------- + +// Measure a string in logical pixels (pre-DPI-scaling) using the same font backend as the renderer. +measure_text :: proc( + str: string, + font_id: Font_Id, + font_size: u16 = 44, + allocator := context.temp_allocator, +) -> [2]f32 { + c_str := strings.clone_to_cstring(str, allocator) + w, h: c.int + if !sdl_ttf.GetStringSize(get_font(font_id, font_size), c_str, 0, &w, &h) { + log.panicf("Failed to measure text: %s", sdl.GetError()) + } + return {f32(w) / GLOB.dpi_scaling, f32(h) / GLOB.dpi_scaling} +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Text anchor helpers ----------- +// --------------------------------------------------------------------------------------------------------------------- + +center_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return size * 0.5 +} + +top_left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + return {0, 0} +} + +top_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x * 0.5, 0} +} + +top_right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x, 0} +} + +left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {0, size.y * 0.5} +} + +right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x, size.y * 0.5} +} + +bottom_left_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {0, size.y} +} + +bottom_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return {size.x * 0.5, size.y} +} + +bottom_right_of_text :: proc(str: string, font_id: Font_Id, font_size: u16 = 44) -> [2]f32 { + size := measure_text(str, font_id, font_size) + return size +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Cache management -------------- +// --------------------------------------------------------------------------------------------------------------------- + +// Destroy all cached text objects. Call on scene transitions, view changes, or periodically +// in apps that produce many distinct cached text entries over time. +// After calling this, subsequent text draws with an `id` will re-create their cache entries. +clear_text_cache :: proc() { + for _, sdl_text in GLOB.text_cache.cache { + sdl_ttf.DestroyText(sdl_text) + } + clear(&GLOB.text_cache.cache) +} + +// Destroy a specific cached text entry by its string id (same string used when drawing). +// Uses the same hash as Clay's element IDs, so this also works for clearing Clay text entries +// by passing the same string used in clay.ID("..."). +// No-op if the id is not in the cache. +clear_text_cache_entry :: proc(id: string) { + key := text_cache_hash(id) + sdl_text, ok := GLOB.text_cache.cache[key] + if ok { + sdl_ttf.DestroyText(sdl_text) + delete_key(&GLOB.text_cache.cache, key) + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// ----- Internal cache lifecycle ------ +// --------------------------------------------------------------------------------------------------------------------- + @(private, require_results) init_text_cache :: proc( device: ^sdl.GPUDevice, @@ -132,6 +298,9 @@ destroy_text_cache :: proc() { for _, font in GLOB.text_cache.sdl_fonts { sdl_ttf.CloseFont(font) } + for _, sdl_text in GLOB.text_cache.cache { + sdl_ttf.DestroyText(sdl_text) + } delete(GLOB.text_cache.sdl_fonts) delete(GLOB.text_cache.font_bytes) delete(GLOB.text_cache.cache)