package draw

import "core:c"
import "core:log"
import "core:math"
import "core:mem"
import sdl "vendor:sdl3"
import sdl_ttf "vendor:sdl3/ttf"

//----- Vertex layout ----------------------------------

// Vertex layout for tessellated and text geometry.
// IMPORTANT: `color` must be premultiplied alpha (RGB channels pre-scaled by alpha).
// The tessellated fragment shader passes vertex color through directly — it does NOT
// premultiply. The blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied-over).
// Use `premultiply_color` when constructing vertices manually for `prepare_shape`.
Vertex_2D :: struct {
	position: Vec2,
	uv:       [2]f32,
	color:    Color,
}

//INTERNAL
Text_Batch :: struct {
	atlas_texture: ^sdl.GPUTexture,
	vertex_start:  u32,
	vertex_count:  u32,
	index_start:   u32,
	index_count:   u32,
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Primitive types ------------
// ---------------------------------------------------------------------------------------------------------------------

// The SDF path evaluates one of four signed distance functions per primitive, dispatched
// by Shape_Kind encoded in the low byte of Core_2D_Primitive.flags:
//
//   RRect    — rounded rectangle with per-corner radii (sdRoundedBox). Also covers circles
//              (uniform radii = half-size), capsule-style line segments (rotated, max rounding),
//              and other RRect-reducible shapes.
//   NGon     — regular polygon with N sides and optional rounding.
//   Ellipse  — approximate ellipse (non-exact SDF, suitable for UI but not for shape merging).
//   Ring_Arc — annular ring with optional angular clipping. Covers full rings, partial arcs,
//              pie slices (inner_radius = 0), and loading spinners.
//INTERNAL
Shape_Kind :: enum u8 {
	Solid    = 0, // tessellated path (mode marker; not a real SDF kind)
	RRect    = 1,
	NGon     = 2,
	Ellipse  = 3,
	Ring_Arc = 4,
}

//INTERNAL
Shape_Flag :: enum u8 {
	Textured, // bit 0: sample texture using uv_rect (mutually exclusive with Gradient via Brush union)
	Gradient, // bit 1: 2-color gradient using effects.gradient_color as end/outer color
	Gradient_Radial, // bit 2: if set with Gradient, radial from center; else linear at angle
	Outline, // bit 3: outer outline band using effects.outline_color; CPU expands bounds by outline_width
	Rotated, // bit 4: shape has non-zero rotation; rotation_sc contains packed sin/cos
	Arc_Narrow, // bit 5: ring arc span ≤ π — intersect half-planes. Neither Arc bit = full ring.
	Arc_Wide, // bit 6: ring arc span > π — union half-planes. Neither Arc bit = full ring.
}

//INTERNAL
Shape_Flags :: bit_set[Shape_Flag;u8]

//INTERNAL
RRect_Params :: struct {
	half_size:    [2]f32,
	radii:        [4]f32,
	half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
	_:            f32,
}

//INTERNAL
NGon_Params :: struct {
	radius:       f32,
	sides:        f32,
	half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
	_:            [5]f32,
}

//INTERNAL
Ellipse_Params :: struct {
	radii:        [2]f32,
	half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
	_:            [5]f32,
}

//INTERNAL
Ring_Arc_Params :: struct {
	inner_radius: f32, // inner radius in physical pixels (0 for pie slice)
	outer_radius: f32, // outer radius in physical pixels
	normal_start: [2]f32, // pre-computed outward normal of start edge: (sin(start), -cos(start))
	normal_end:   [2]f32, // pre-computed outward normal of end edge: (-sin(end), cos(end))
	half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
	_:            f32,
}

//INTERNAL
Shape_Params :: struct #raw_union {
	rrect:    RRect_Params,
	ngon:     NGon_Params,
	ellipse:  Ellipse_Params,
	ring_arc: Ring_Arc_Params,
	raw:      [8]f32,
}
#assert(size_of(Shape_Params) == 32)

// GPU-side storage for 2-color gradient parameters and/or outline parameters.
// Packed into 16 bytes. Independent from uv_rect — texture and outline can coexist.
// The shader reads gradient_color and outline_color via unpackUnorm4x8.
// gradient_dir_sc stores the pre-computed gradient direction as (cos, sin) in f16 pair
// via unpackHalf2x16. outline_packed stores outline_width as f16 via unpackHalf2x16.
//INTERNAL
Gradient_Outline :: struct {
	gradient_color:  Color, //  0: end (linear) or outer (radial) gradient color
	outline_color:   Color, //  4: outline band color
	gradient_dir_sc: u32, //  8: packed f16 pair: low = cos(angle), high = sin(angle) — pre-computed gradient direction
	outline_packed:  u32, // 12: packed f16 pair: low = outline_width (f16, physical pixels), high = reserved
}
#assert(size_of(Gradient_Outline) == 16)

// GPU layout: 96 bytes, std430-compatible. The shader declares this as a storage buffer struct.
// The low byte of `flags` encodes the Shape_Kind (0 = tessellated, 1-4 = SDF kinds).
// Bits 8-15 encode Shape_Flags (Textured, Gradient, Gradient_Radial, Outline, Rotated, Arc_Narrow, Arc_Wide).
// rotation_sc stores pre-computed sin/cos of the rotation angle as a packed f16 pair,
// avoiding per-pixel trigonometry in the fragment shader. Only read when .Rotated is set.
//
// Named Core_2D_Primitive (not just Primitive) to disambiguate from Gaussian_Blur_Primitive
// (and any future per-effect primitive types) in backdrop.odin. Each path/effect's primitive
// type has its own GPU layout and fragment-shader contract; pairing each with its own
// primitive type keeps cross-references unambiguous when grepping the codebase.
//INTERNAL
Core_2D_Primitive :: struct {
	bounds:      [4]f32, //  0: min_x, min_y, max_x, max_y (world-space, pre-DPI)
	color:       Color, // 16: u8x4, fill color / gradient start color / texture tint
	flags:       u32, // 20: low byte = Shape_Kind, bits 8+ = Shape_Flags
	rotation_sc: u32, // 24: packed f16 pair: low = sin(angle), high = cos(angle). Requires .Rotated flag.
	_pad:        f32, // 28: reserved for future use
	params:      Shape_Params, // 32: per-kind shape parameters (raw union, 32 bytes)
	uv_rect:     [4]f32, // 64: texture UV coordinates (u_min, v_min, u_max, v_max). Read when .Textured.
	effects:     Gradient_Outline, // 80: gradient and/or outline parameters. Read when .Gradient and/or .Outline.
}
#assert(size_of(Core_2D_Primitive) == 96)

// Pack shape kind and flags into the Core_2D_Primitive.flags field. The low byte encodes the
// Shape_Kind (which also serves as the SDF mode marker — kind > 0 means SDF path). The
// tessellated path leaves the field at 0 (Solid kind, set by vertex shader zero-initialization).
//INTERNAL
pack_kind_flags :: #force_inline proc(kind: Shape_Kind, flags: Shape_Flags) -> u32 {
	return u32(kind) | (u32(transmute(u8)flags) << 8)
}

// Pack two f16 values into a single u32 for GPU consumption via unpackHalf2x16.
// Used to pack gradient_dir_sc (cos/sin) and outline_packed (width/reserved) in Gradient_Outline.
//INTERNAL
pack_f16_pair :: #force_inline proc(low, high: f16) -> u32 {
	return u32(transmute(u16)low) | (u32(transmute(u16)high) << 16)
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Subsystem lifecycle ------------
// ---------------------------------------------------------------------------------------------------------------------

//INTERNAL
Core_2D :: struct {
	sdl_pipeline:     ^sdl.GPUGraphicsPipeline,
	vertex_buffer:    Buffer,
	index_buffer:     Buffer,
	unit_quad_buffer: ^sdl.GPUBuffer,
	primitive_buffer: Buffer,
	white_texture:    ^sdl.GPUTexture,
	sampler:          ^sdl.GPUSampler,
}

// MSAA is not supported by levlib (see init's doc comment in draw.odin); the PSO is hard-wired
// to single-sample. SDF text and shapes provide analytical AA via smoothstep; tessellated user
// geometry is not anti-aliased.
//INTERNAL
create_core_2d :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> (core_2d: Core_2D, ok: bool) {
	// On failure, clean up any partially-created resources
	defer if !ok {
		if core_2d.sampler != nil do sdl.ReleaseGPUSampler(device, core_2d.sampler)
		if core_2d.white_texture != nil do sdl.ReleaseGPUTexture(device, core_2d.white_texture)
		if core_2d.unit_quad_buffer != nil do sdl.ReleaseGPUBuffer(device, core_2d.unit_quad_buffer)
		if core_2d.primitive_buffer.gpu != nil do destroy_buffer(device, &core_2d.primitive_buffer)
		if core_2d.index_buffer.gpu != nil do destroy_buffer(device, &core_2d.index_buffer)
		if core_2d.vertex_buffer.gpu != nil do destroy_buffer(device, &core_2d.vertex_buffer)
		if core_2d.sdl_pipeline != nil do sdl.ReleaseGPUGraphicsPipeline(device, core_2d.sdl_pipeline)
	}

	active_shader_formats := sdl.GetGPUShaderFormats(device)
	if PLATFORM_SHADER_FORMAT_FLAG not_in active_shader_formats {
		log.errorf(
			"draw: no embedded shader matches active GPU formats; this build supports %v but device reports %v",
			PLATFORM_SHADER_FORMAT,
			active_shader_formats,
		)
		return core_2d, false
	}

	log.debug("Loaded", len(BASE_VERT_2D_RAW), "vert bytes")
	log.debug("Loaded", len(BASE_FRAG_2D_RAW), "frag bytes")

	vert_info := sdl.GPUShaderCreateInfo {
		code_size           = len(BASE_VERT_2D_RAW),
		code                = raw_data(BASE_VERT_2D_RAW),
		entrypoint          = SHADER_ENTRY,
		format              = {PLATFORM_SHADER_FORMAT_FLAG},
		stage               = .VERTEX,
		num_uniform_buffers = 1,
		num_storage_buffers = 1,
	}

	frag_info := sdl.GPUShaderCreateInfo {
		code_size    = len(BASE_FRAG_2D_RAW),
		code         = raw_data(BASE_FRAG_2D_RAW),
		entrypoint   = SHADER_ENTRY,
		format       = {PLATFORM_SHADER_FORMAT_FLAG},
		stage        = .FRAGMENT,
		num_samplers = 1,
	}

	vert_shader := sdl.CreateGPUShader(device, vert_info)
	if vert_shader == nil {
		log.errorf("Could not create draw vertex shader: %s", sdl.GetError())
		return core_2d, false
	}

	frag_shader := sdl.CreateGPUShader(device, frag_info)
	if frag_shader == nil {
		sdl.ReleaseGPUShader(device, vert_shader)
		log.errorf("Could not create draw fragment shader: %s", sdl.GetError())
		return core_2d, false
	}

	vertex_attributes: [3]sdl.GPUVertexAttribute = {
		// position (GLSL location 0)
		sdl.GPUVertexAttribute{buffer_slot = 0, location = 0, format = .FLOAT2, offset = 0},
		// uv (GLSL location 1)
		sdl.GPUVertexAttribute{buffer_slot = 0, location = 1, format = .FLOAT2, offset = size_of([2]f32)},
		// color (GLSL location 2, u8x4 normalized to float by GPU)
		sdl.GPUVertexAttribute{buffer_slot = 0, location = 2, format = .UBYTE4_NORM, offset = size_of([2]f32) * 2},
	}

	pipeline_info := sdl.GPUGraphicsPipelineCreateInfo {
		vertex_shader = vert_shader,
		fragment_shader = frag_shader,
		primitive_type = .TRIANGLELIST,
		multisample_state = sdl.GPUMultisampleState{sample_count = ._1},
		target_info = sdl.GPUGraphicsPipelineTargetInfo {
			color_target_descriptions = &sdl.GPUColorTargetDescription {
				format = sdl.GetGPUSwapchainTextureFormat(device, window),
				// Premultiplied-alpha blending: src outputs RGB pre-multiplied by alpha,
				// so src factor is ONE (not SRC_ALPHA). This eliminates the per-pixel
				// divide in the outline path and is the standard blend mode used by
				// Skia, Flutter, and GPUI.
				blend_state = sdl.GPUColorTargetBlendState {
					enable_blend = true,
					enable_color_write_mask = true,
					src_color_blendfactor = .ONE,
					dst_color_blendfactor = .ONE_MINUS_SRC_ALPHA,
					color_blend_op = .ADD,
					src_alpha_blendfactor = .ONE,
					dst_alpha_blendfactor = .ONE_MINUS_SRC_ALPHA,
					alpha_blend_op = .ADD,
					color_write_mask = sdl.GPUColorComponentFlags{.R, .G, .B, .A},
				},
			},
			num_color_targets         = 1,
		},
		vertex_input_state = sdl.GPUVertexInputState {
			vertex_buffer_descriptions = &sdl.GPUVertexBufferDescription {
				slot = 0,
				input_rate = .VERTEX,
				pitch = size_of(Vertex_2D),
			},
			num_vertex_buffers = 1,
			vertex_attributes = raw_data(vertex_attributes[:]),
			num_vertex_attributes = 3,
		},
	}

	core_2d.sdl_pipeline = sdl.CreateGPUGraphicsPipeline(device, pipeline_info)
	// Shaders are no longer needed regardless of pipeline creation success
	sdl.ReleaseGPUShader(device, vert_shader)
	sdl.ReleaseGPUShader(device, frag_shader)
	if core_2d.sdl_pipeline == nil {
		log.errorf("Failed to create draw graphics pipeline: %s", sdl.GetError())
		return core_2d, false
	}

	// Create vertex buffer
	vert_buf_ok: bool
	core_2d.vertex_buffer, vert_buf_ok = create_buffer(
		device,
		size_of(Vertex_2D) * BUFFER_INIT_SIZE,
		sdl.GPUBufferUsageFlags{.VERTEX},
	)
	if !vert_buf_ok do return core_2d, false

	// Create index buffer (used by text)
	idx_buf_ok: bool
	core_2d.index_buffer, idx_buf_ok = create_buffer(
		device,
		size_of(c.int) * BUFFER_INIT_SIZE,
		sdl.GPUBufferUsageFlags{.INDEX},
	)
	if !idx_buf_ok do return core_2d, false

	// Create primitive storage buffer (used by SDF instanced drawing)
	prim_buf_ok: bool
	core_2d.primitive_buffer, prim_buf_ok = create_buffer(
		device,
		size_of(Core_2D_Primitive) * BUFFER_INIT_SIZE,
		sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ},
	)
	if !prim_buf_ok do return core_2d, false

	// Create static 6-vertex unit quad buffer (two triangles, TRIANGLELIST)
	core_2d.unit_quad_buffer = sdl.CreateGPUBuffer(
		device,
		sdl.GPUBufferCreateInfo{usage = {.VERTEX}, size = 6 * size_of(Vertex_2D)},
	)
	if core_2d.unit_quad_buffer == nil {
		log.errorf("Failed to create unit quad buffer: %s", sdl.GetError())
		return core_2d, false
	}

	// Create 1x1 white pixel texture
	core_2d.white_texture = sdl.CreateGPUTexture(
		device,
		sdl.GPUTextureCreateInfo {
			type = .D2,
			format = .R8G8B8A8_UNORM,
			usage = {.SAMPLER},
			width = 1,
			height = 1,
			layer_count_or_depth = 1,
			num_levels = 1,
			sample_count = ._1,
		},
	)
	if core_2d.white_texture == nil {
		log.errorf("Failed to create white pixel texture: %s", sdl.GetError())
		return core_2d, false
	}

	// Upload white pixel and unit quad data in a single command buffer
	white_pixel := Color{255, 255, 255, 255}
	white_transfer_buf := sdl.CreateGPUTransferBuffer(
		device,
		sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(white_pixel)},
	)
	if white_transfer_buf == nil {
		log.errorf("Failed to create white pixel transfer buffer: %s", sdl.GetError())
		return core_2d, false
	}
	defer sdl.ReleaseGPUTransferBuffer(device, white_transfer_buf)

	white_ptr := sdl.MapGPUTransferBuffer(device, white_transfer_buf, false)
	if white_ptr == nil {
		log.errorf("Failed to map white pixel transfer buffer: %s", sdl.GetError())
		return core_2d, false
	}
	mem.copy(white_ptr, &white_pixel, size_of(white_pixel))
	sdl.UnmapGPUTransferBuffer(device, white_transfer_buf)

	quad_verts := [6]Vertex_2D {
		{position = {0, 0}},
		{position = {1, 0}},
		{position = {0, 1}},
		{position = {0, 1}},
		{position = {1, 0}},
		{position = {1, 1}},
	}
	quad_transfer_buf := sdl.CreateGPUTransferBuffer(
		device,
		sdl.GPUTransferBufferCreateInfo{usage = .UPLOAD, size = size_of(quad_verts)},
	)
	if quad_transfer_buf == nil {
		log.errorf("Failed to create unit quad transfer buffer: %s", sdl.GetError())
		return core_2d, false
	}
	defer sdl.ReleaseGPUTransferBuffer(device, quad_transfer_buf)

	quad_ptr := sdl.MapGPUTransferBuffer(device, quad_transfer_buf, false)
	if quad_ptr == nil {
		log.errorf("Failed to map unit quad transfer buffer: %s", sdl.GetError())
		return core_2d, false
	}
	mem.copy(quad_ptr, &quad_verts, size_of(quad_verts))
	sdl.UnmapGPUTransferBuffer(device, quad_transfer_buf)

	upload_cmd_buffer := sdl.AcquireGPUCommandBuffer(device)
	if upload_cmd_buffer == nil {
		log.errorf("Failed to acquire command buffer for init upload: %s", sdl.GetError())
		return core_2d, false
	}
	upload_pass := sdl.BeginGPUCopyPass(upload_cmd_buffer)

	sdl.UploadToGPUTexture(
		upload_pass,
		sdl.GPUTextureTransferInfo{transfer_buffer = white_transfer_buf},
		sdl.GPUTextureRegion{texture = core_2d.white_texture, w = 1, h = 1, d = 1},
		false,
	)

	sdl.UploadToGPUBuffer(
		upload_pass,
		sdl.GPUTransferBufferLocation{transfer_buffer = quad_transfer_buf},
		sdl.GPUBufferRegion{buffer = core_2d.unit_quad_buffer, offset = 0, size = size_of(quad_verts)},
		false,
	)

	sdl.EndGPUCopyPass(upload_pass)
	if !sdl.SubmitGPUCommandBuffer(upload_cmd_buffer) {
		log.errorf("Failed to submit init upload command buffer: %s", sdl.GetError())
		return core_2d, false
	}

	log.debug("White pixel texture and unit quad buffer created and uploaded")

	// Create sampler (shared by shapes and text)
	core_2d.sampler = sdl.CreateGPUSampler(
		device,
		sdl.GPUSamplerCreateInfo {
			min_filter = .LINEAR,
			mag_filter = .LINEAR,
			mipmap_mode = .LINEAR,
			address_mode_u = .CLAMP_TO_EDGE,
			address_mode_v = .CLAMP_TO_EDGE,
			address_mode_w = .CLAMP_TO_EDGE,
		},
	)
	if core_2d.sampler == nil {
		log.errorf("Could not create GPU sampler: %s", sdl.GetError())
		return core_2d, false
	}

	log.debug("Done creating core 2D subsystem")
	return core_2d, true
}

//INTERNAL
destroy_core_2d :: proc(device: ^sdl.GPUDevice, core: ^Core_2D) {
	destroy_buffer(device, &core.vertex_buffer)
	destroy_buffer(device, &core.index_buffer)
	destroy_buffer(device, &core.primitive_buffer)
	if core.unit_quad_buffer != nil {
		sdl.ReleaseGPUBuffer(device, core.unit_quad_buffer)
	}
	sdl.ReleaseGPUTexture(device, core.white_texture)
	sdl.ReleaseGPUSampler(device, core.sampler)
	sdl.ReleaseGPUGraphicsPipeline(device, core.sdl_pipeline)
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Upload and render ------------
// ---------------------------------------------------------------------------------------------------------------------

//----- Vertex uniforms ----------------------------------

//INTERNAL
Core_2D_Mode :: enum u32 {
	Tessellated = 0,
	SDF         = 1,
}

//INTERNAL
Vertex_Uniforms_2D :: struct {
	projection: matrix[4, 4]f32,
	scale:      f32,
	mode:       Core_2D_Mode,
}

// Push projection, dpi scale, and rendering mode as a single uniform block (slot 0).
//INTERNAL
push_globals :: proc(
	cmd_buffer: ^sdl.GPUCommandBuffer,
	width: f32,
	height: f32,
	mode: Core_2D_Mode = .Tessellated,
) {
	globals := Vertex_Uniforms_2D {
		projection = ortho_rh(
			left = 0.0,
			top = 0.0,
			right = f32(width),
			bottom = f32(height),
			near = -1.0,
			far = 1.0,
		),
		scale      = GLOB.dpi_scaling,
		mode       = mode,
	}

	sdl.PushGPUVertexUniformData(cmd_buffer, 0, &globals, size_of(Vertex_Uniforms_2D))
}

//----- Per-frame upload ----------------------------------

//INTERNAL
upload :: proc(device: ^sdl.GPUDevice, pass: ^sdl.GPUCopyPass) {
	// Upload vertices (shapes then text into one buffer)
	shape_vert_count := u32(len(GLOB.tmp_shape_verts))
	text_vert_count := u32(len(GLOB.tmp_text_verts))
	total_vert_count := shape_vert_count + text_vert_count

	if total_vert_count > 0 {
		total_vert_size := total_vert_count * size_of(Vertex_2D)
		shape_vert_size := shape_vert_count * size_of(Vertex_2D)
		text_vert_size := text_vert_count * size_of(Vertex_2D)

		grow_buffer_if_needed(
			device,
			&GLOB.core_2d.vertex_buffer,
			total_vert_size,
			sdl.GPUBufferUsageFlags{.VERTEX},
		)

		vert_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.vertex_buffer.transfer, false)
		if vert_array == nil {
			log.panicf("Failed to map vertex transfer buffer: %s", sdl.GetError())
		}
		if shape_vert_size > 0 {
			mem.copy(vert_array, raw_data(GLOB.tmp_shape_verts), int(shape_vert_size))
		}
		if text_vert_size > 0 {
			mem.copy(
				rawptr(uintptr(vert_array) + uintptr(shape_vert_size)),
				raw_data(GLOB.tmp_text_verts),
				int(text_vert_size),
			)
		}
		sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.vertex_buffer.transfer)

		sdl.UploadToGPUBuffer(
			pass,
			sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.vertex_buffer.transfer},
			sdl.GPUBufferRegion{buffer = GLOB.core_2d.vertex_buffer.gpu, offset = 0, size = total_vert_size},
			false,
		)
	}

	// Upload text indices
	index_count := u32(len(GLOB.tmp_text_indices))
	if index_count > 0 {
		index_size := index_count * size_of(c.int)

		grow_buffer_if_needed(device, &GLOB.core_2d.index_buffer, index_size, sdl.GPUBufferUsageFlags{.INDEX})

		idx_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.index_buffer.transfer, false)
		if idx_array == nil {
			log.panicf("Failed to map index transfer buffer: %s", sdl.GetError())
		}
		mem.copy(idx_array, raw_data(GLOB.tmp_text_indices), int(index_size))
		sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.index_buffer.transfer)

		sdl.UploadToGPUBuffer(
			pass,
			sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.index_buffer.transfer},
			sdl.GPUBufferRegion{buffer = GLOB.core_2d.index_buffer.gpu, offset = 0, size = index_size},
			false,
		)
	}

	// Upload SDF primitives
	prim_count := u32(len(GLOB.tmp_primitives))
	if prim_count > 0 {
		prim_size := prim_count * size_of(Core_2D_Primitive)

		grow_buffer_if_needed(
			device,
			&GLOB.core_2d.primitive_buffer,
			prim_size,
			sdl.GPUBufferUsageFlags{.GRAPHICS_STORAGE_READ},
		)

		prim_array := sdl.MapGPUTransferBuffer(device, GLOB.core_2d.primitive_buffer.transfer, false)
		if prim_array == nil {
			log.panicf("Failed to map primitive transfer buffer: %s", sdl.GetError())
		}
		mem.copy(prim_array, raw_data(GLOB.tmp_primitives), int(prim_size))
		sdl.UnmapGPUTransferBuffer(device, GLOB.core_2d.primitive_buffer.transfer)

		sdl.UploadToGPUBuffer(
			pass,
			sdl.GPUTransferBufferLocation{transfer_buffer = GLOB.core_2d.primitive_buffer.transfer},
			sdl.GPUBufferRegion{buffer = GLOB.core_2d.primitive_buffer.gpu, offset = 0, size = prim_size},
			false,
		)
	}
}

//----- Layer dispatch ----------------------------------

//INTERNAL
draw_layer :: proc(
	device: ^sdl.GPUDevice,
	window: ^sdl.Window,
	cmd_buffer: ^sdl.GPUCommandBuffer,
	render_texture: ^sdl.GPUTexture,
	swapchain_width: u32,
	swapchain_height: u32,
	clear_color: [4]f32,
	layer: ^Layer,
) {
	if layer.sub_batch_len == 0 {
		if !GLOB.cleared {
			pass := sdl.BeginGPURenderPass(
				cmd_buffer,
				&sdl.GPUColorTargetInfo {
					texture = render_texture,
					clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]},
					load_op = .CLEAR,
					store_op = .STORE,
				},
				1,
				nil,
			)
			sdl.EndGPURenderPass(pass)
			GLOB.cleared = true
		}
		return
	}

	bracket_start_abs := find_first_backdrop_in_layer(layer)
	layer_end_abs := int(layer.sub_batch_start + layer.sub_batch_len)

	if bracket_start_abs < 0 {
		// Fast path: no backdrop in this layer; render the whole sub-batch range in one pass.
		render_layer_sub_batch_range(
			cmd_buffer,
			render_texture,
			swapchain_width,
			swapchain_height,
			clear_color,
			layer,
			int(layer.sub_batch_start),
			layer_end_abs,
		)
		return
	}

	// Bracketed layer: Pass A → backdrop bracket → Pass B.
	// See README.md § "Backdrop pipeline" for the full ordering semantics.
	render_layer_sub_batch_range(
		cmd_buffer,
		render_texture,
		swapchain_width,
		swapchain_height,
		clear_color,
		layer,
		int(layer.sub_batch_start),
		bracket_start_abs,
	)

	run_backdrop_bracket(cmd_buffer, layer, swapchain_width, swapchain_height)

	// Pass B: render the [bracket_start_abs, layer_end_abs) range. .Backdrop sub-batches in
	// this range are dispatched by the bracket above and ignored here (the .Backdrop case in
	// the inner switch is a no-op). LOAD is implied because Pass A or the bracket's V-
	// composite has already touched render_texture.
	render_layer_sub_batch_range(
		cmd_buffer,
		render_texture,
		swapchain_width,
		swapchain_height,
		clear_color,
		layer,
		bracket_start_abs,
		layer_end_abs,
	)
}

// Render a sub-range of a layer's sub-batches in a single render pass. Iterates the layer's
// scissors and walks each scissor's sub-batches, dispatching by kind. The `range_start_abs`
// and `range_end_abs` parameters are absolute indices into GLOB.tmp_sub_batches; only sub-
// batches within `[range_start_abs, range_end_abs)` are drawn.
//
// .Backdrop sub-batches in the range are always silently skipped — they are dispatched by
// run_backdrop_bracket, not here. The empty .Backdrop case in the inner switch enforces this.
//
// Render-pass setup mirrors the original draw_layer: clear-or-load based on GLOB.cleared,
// pipeline + storage + index buffer bound up front, then per-batch state tracking. After this
// proc returns, GLOB.cleared is guaranteed true.
//
// If the range is empty after filtering (no eligible sub-batches at all), this proc still
// honors the no-clear-yet contract by issuing a clear-only pass when needed; otherwise it
// returns without opening a render pass.
//INTERNAL
render_layer_sub_batch_range :: proc(
	cmd_buffer: ^sdl.GPUCommandBuffer,
	render_texture: ^sdl.GPUTexture,
	swapchain_width: u32,
	swapchain_height: u32,
	clear_color: [4]f32,
	layer: ^Layer,
	range_start_abs: int,
	range_end_abs: int,
) {
	if range_start_abs >= range_end_abs {
		// Empty range. If we still owe a clear, do a clear-only pass; otherwise nothing to do.
		if !GLOB.cleared {
			pass := sdl.BeginGPURenderPass(
				cmd_buffer,
				&sdl.GPUColorTargetInfo {
					texture = render_texture,
					clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]},
					load_op = .CLEAR,
					store_op = .STORE,
				},
				1,
				nil,
			)
			sdl.EndGPURenderPass(pass)
			GLOB.cleared = true
		}
		return
	}

	render_pass := sdl.BeginGPURenderPass(
		cmd_buffer,
		&sdl.GPUColorTargetInfo {
			texture = render_texture,
			clear_color = sdl.FColor{clear_color[0], clear_color[1], clear_color[2], clear_color[3]},
			load_op = GLOB.cleared ? .LOAD : .CLEAR,
			store_op = .STORE,
		},
		1,
		nil,
	)
	GLOB.cleared = true

	sdl.BindGPUGraphicsPipeline(render_pass, GLOB.core_2d.sdl_pipeline)

	// Bind storage buffer (read by vertex shader in SDF mode)
	sdl.BindGPUVertexStorageBuffers(render_pass, 0, ([^]^sdl.GPUBuffer)(&GLOB.core_2d.primitive_buffer.gpu), 1)

	// Always bind index buffer — harmless if no indexed draws are issued
	sdl.BindGPUIndexBuffer(
		render_pass,
		sdl.GPUBufferBinding{buffer = GLOB.core_2d.index_buffer.gpu, offset = 0},
		._32BIT,
	)

	// Shorthand aliases for frequently-used pipeline resources
	main_vert_buf := GLOB.core_2d.vertex_buffer.gpu
	unit_quad := GLOB.core_2d.unit_quad_buffer
	white_texture := GLOB.core_2d.white_texture
	sampler := GLOB.core_2d.sampler
	width := f32(swapchain_width)
	height := f32(swapchain_height)

	// Initial GPU state: tessellated mode, main vertex buffer, no atlas bound yet
	push_globals(cmd_buffer, width, height, .Tessellated)
	sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)

	current_mode: Core_2D_Mode = .Tessellated
	current_vert_buf := main_vert_buf
	current_atlas: ^sdl.GPUTexture
	current_sampler := sampler

	// Text vertices live after shape vertices in the GPU vertex buffer
	text_vertex_gpu_base := u32(len(GLOB.tmp_shape_verts))

	for &scissor in GLOB.scissors[layer.scissor_start:][:layer.scissor_len] {
		// Intersect this scissor's sub-batch span with the requested range.
		scissor_start := int(scissor.sub_batch_start)
		scissor_end := scissor_start + int(scissor.sub_batch_len)
		effective_start := max(scissor_start, range_start_abs)
		effective_end := min(scissor_end, range_end_abs)
		if effective_start >= effective_end do continue

		sdl.SetGPUScissor(render_pass, scissor.bounds)

		for abs_idx in effective_start ..< effective_end {
			batch := &GLOB.tmp_sub_batches[abs_idx]
			switch batch.kind {
			case .Tessellated:
				if current_mode != .Tessellated {
					push_globals(cmd_buffer, width, height, .Tessellated)
					current_mode = .Tessellated
				}
				if current_vert_buf != main_vert_buf {
					sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)
					current_vert_buf = main_vert_buf
				}
				// Determine texture and sampler for this batch
				batch_texture: ^sdl.GPUTexture = white_texture
				batch_sampler: ^sdl.GPUSampler = sampler
				if batch.texture_id != INVALID_TEXTURE {
					if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil {
						batch_texture = bound_texture
					}
					batch_sampler = get_sampler(batch.sampler)
				}
				if current_atlas != batch_texture || current_sampler != batch_sampler {
					sdl.BindGPUFragmentSamplers(
						render_pass,
						0,
						&sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler},
						1,
					)
					current_atlas = batch_texture
					current_sampler = batch_sampler
				}
				sdl.DrawGPUPrimitives(render_pass, batch.count, 1, batch.offset, 0)

			case .Text:
				if current_mode != .Tessellated {
					push_globals(cmd_buffer, width, height, .Tessellated)
					current_mode = .Tessellated
				}
				if current_vert_buf != main_vert_buf {
					sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)
					current_vert_buf = main_vert_buf
				}
				text_batch := &GLOB.tmp_text_batches[batch.offset]
				if current_atlas != text_batch.atlas_texture {
					sdl.BindGPUFragmentSamplers(
						render_pass,
						0,
						&sdl.GPUTextureSamplerBinding{texture = text_batch.atlas_texture, sampler = sampler},
						1,
					)
					current_atlas = text_batch.atlas_texture
				}
				sdl.DrawGPUIndexedPrimitives(
					render_pass,
					text_batch.index_count,
					1,
					text_batch.index_start,
					i32(text_vertex_gpu_base + text_batch.vertex_start),
					0,
				)

			case .SDF:
				if current_mode != .SDF {
					push_globals(cmd_buffer, width, height, .SDF)
					current_mode = .SDF
				}
				if current_vert_buf != unit_quad {
					sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = unit_quad, offset = 0}, 1)
					current_vert_buf = unit_quad
				}
				// Determine texture and sampler for this batch
				batch_texture: ^sdl.GPUTexture = white_texture
				batch_sampler: ^sdl.GPUSampler = sampler
				if batch.texture_id != INVALID_TEXTURE {
					if bound_texture := texture_gpu_handle(batch.texture_id); bound_texture != nil {
						batch_texture = bound_texture
					}
					batch_sampler = get_sampler(batch.sampler)
				}
				if current_atlas != batch_texture || current_sampler != batch_sampler {
					sdl.BindGPUFragmentSamplers(
						render_pass,
						0,
						&sdl.GPUTextureSamplerBinding{texture = batch_texture, sampler = batch_sampler},
						1,
					)
					current_atlas = batch_texture
					current_sampler = batch_sampler
				}
				sdl.DrawGPUPrimitives(render_pass, 6, batch.count, 0, batch.offset)

			case .Backdrop:
			// Always a no-op here. Backdrop sub-batches are dispatched by run_backdrop_bracket;
			// when this proc encounters one (only possible in Pass B, since Pass A and the no-
			// backdrop fast path both stop their range before any .Backdrop index), we skip it.
			}
		}
	}

	sdl.EndGPURenderPass(render_pass)
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Submission helpers ------------
// ---------------------------------------------------------------------------------------------------------------------

// Submit shape vertices (colored triangles) to the given layer for rendering.
// TODO: Should probably be renamed to better match tesselated naming conventions in the library.
prepare_shape :: proc(layer: ^Layer, vertices: []Vertex_2D) {
	if len(vertices) == 0 do return
	offset := u32(len(GLOB.tmp_shape_verts))
	append(&GLOB.tmp_shape_verts, ..vertices)
	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
	append_or_extend_sub_batch(scissor, layer, .Tessellated, offset, u32(len(vertices)))
}

// Submit an SDF primitive to the given layer for rendering. Requires the caller to build a
// Core_2D_Primitive directly, which is the internal GPU-layout struct.
//INTERNAL
prepare_sdf_primitive :: proc(layer: ^Layer, prim: Core_2D_Primitive) {
	offset := u32(len(GLOB.tmp_primitives))
	append(&GLOB.tmp_primitives, prim)
	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
	append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1)
}

// Submit an SDF primitive with optional texture binding.
// The texture-aware counterpart of `prepare_sdf_primitive`; lets shape procs route a
// texture_id and sampler into the sub-batch without growing the public API.
//INTERNAL
prepare_sdf_primitive_ex :: proc(
	layer: ^Layer,
	prim: Core_2D_Primitive,
	texture_id: Texture_Id = INVALID_TEXTURE,
	sampler: Sampler_Preset = DFT_SAMPLER,
) {
	offset := u32(len(GLOB.tmp_primitives))
	append(&GLOB.tmp_primitives, prim)
	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]
	append_or_extend_sub_batch(scissor, layer, .SDF, offset, 1, texture_id, sampler)
}

// Submit a text element to the given layer for rendering.
// Copies SDL_ttf vertices directly (with baked position) and copies indices for indexed drawing.
//INTERNAL
prepare_text :: proc(layer: ^Layer, text: Text) {
	data := sdl_ttf.GetGPUTextDrawData(text.sdl_text)
	if data == nil {
		return // nil is normal for empty text
	}

	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]

	// Snap base position to integer physical pixels to avoid atlas sub-pixel
	// sampling blur (and the off-by-one bottom-row clip that comes with it).
	base_x := math.round(text.position[0] * GLOB.dpi_scaling)
	base_y := math.round(text.position[1] * GLOB.dpi_scaling)

	// Premultiply text color once — reused across all glyph vertices.
	pm_color := premultiply_color(text.color)

	for data != nil {
		vertex_start := u32(len(GLOB.tmp_text_verts))
		index_start := u32(len(GLOB.tmp_text_indices))

		// Copy vertices with baked position offset
		for i in 0 ..< data.num_vertices {
			pos := data.xy[i]
			uv := data.uv[i]
			append(
				&GLOB.tmp_text_verts,
				Vertex_2D{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color},
			)
		}

		// Copy indices directly
		append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices])

		batch_idx := u32(len(GLOB.tmp_text_batches))
		append(
			&GLOB.tmp_text_batches,
			Text_Batch {
				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),
			},
		)

		// Each atlas chunk is a separate sub-batch (different atlas textures can't coalesce)
		append_or_extend_sub_batch(scissor, layer, .Text, batch_idx, 1)

		data = data.next
	}
}

// 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.
//INTERNAL
prepare_text_transformed :: proc(layer: ^Layer, text: Text, transform: Transform_2D) {
	data := sdl_ttf.GetGPUTextDrawData(text.sdl_text)
	if data == nil {
		return
	}

	scissor := &GLOB.scissors[layer.scissor_start + layer.scissor_len - 1]

	// Premultiply text color once — reused across all glyph vertices.
	pm_color := premultiply_color(text.color)

	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_2D{position = apply_transform(transform, {pos.x, -pos.y}), uv = {uv.x, uv.y}, color = pm_color},
			)
		}

		append(&GLOB.tmp_text_indices, ..data.indices[:data.num_indices])

		batch_idx := u32(len(GLOB.tmp_text_batches))
		append(
			&GLOB.tmp_text_batches,
			Text_Batch {
				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
	}
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Primitive builders ------------
// ---------------------------------------------------------------------------------------------------------------------

//----- Internal helpers ----------------------------------

// Resolve Texture_Fill zero-initialized fields to their defaults.
// Odin structs zero-initialize; Color{} and Rectangle{} are all-zero which is not a
// useful tint or UV rect. This proc substitutes sensible defaults for zero values.
//INTERNAL
resolve_texture_defaults :: #force_inline proc(
	tf: Texture_Fill,
) -> (
	tint: Color,
	uv: Rectangle,
	sampler: Sampler_Preset,
) {
	tint = tf.tint == Color{} ? DFT_TINT : tf.tint
	uv = tf.uv_rect == Rectangle{} ? DFT_UV_RECT : tf.uv_rect
	sampler = tf.sampler
	return
}

// Compute the visual center of a center-parametrized shape after applying
// Convention B origin semantics: `center` is where the origin-point lands in
// world space; the visual center is offset by -origin and then rotated around
// the landing point.
//   visual_center = center + R(θ) · (-origin)
// When θ=0: visual_center = center - origin (pure positioning shift).
// When origin={0,0}: visual_center = center (no change).
//INTERNAL
compute_pivot_center :: proc(center: Vec2, origin: Vec2, sin_angle, cos_angle: f32) -> Vec2 {
	if origin == {0, 0} do return center
	return(
		center +
		{cos_angle * (-origin.x) - sin_angle * (-origin.y), sin_angle * (-origin.x) + cos_angle * (-origin.y)} \
	)
}

// Compute the AABB half-extents of a rectangle with half-size (half_width, half_height) rotated by the given cos/sin.
//INTERNAL
rotated_aabb_half_extents :: proc(half_width, half_height, cos_angle, sin_angle: f32) -> [2]f32 {
	cos_abs := abs(cos_angle)
	sin_abs := abs(sin_angle)
	return {half_width * cos_abs + half_height * sin_abs, half_width * sin_abs + half_height * cos_abs}
}

// Pack sin/cos into the Core_2D_Primitive.rotation_sc field as two f16 values.
//INTERNAL
pack_rotation_sc :: #force_inline proc(sin_angle, cos_angle: f32) -> u32 {
	return pack_f16_pair(f16(sin_angle), f16(cos_angle))
}

//----- Shape builders ----------------------------------

// Build an RRect Core_2D_Primitive with bounds, params, and rotation computed from rectangle geometry.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_rrect_primitive :: proc(
	rect: Rectangle,
	radii: Rectangle_Radii,
	origin: Vec2,
	rotation: f32,
	feather_px: f32,
) -> Core_2D_Primitive {
	max_radius := min(rect.width, rect.height) * 0.5
	clamped_top_left := clamp(radii.top_left, 0, max_radius)
	clamped_top_right := clamp(radii.top_right, 0, max_radius)
	clamped_bottom_right := clamp(radii.bottom_right, 0, max_radius)
	clamped_bottom_left := clamp(radii.bottom_left, 0, max_radius)

	half_feather := feather_px * 0.5
	padding := half_feather / GLOB.dpi_scaling
	dpi_scale := GLOB.dpi_scaling

	half_width := rect.width * 0.5
	half_height := rect.height * 0.5
	center_x := rect.x + half_width - origin.x
	center_y := rect.y + half_height - origin.y
	sin_angle: f32 = 0
	cos_angle: f32 = 1
	has_rotation := false

	if needs_transform(origin, rotation) {
		rotation_radians := math.to_radians(rotation)
		sin_angle, cos_angle = math.sincos(rotation_radians)
		has_rotation = rotation != 0
		transform := build_pivot_rotation_sc({rect.x + origin.x, rect.y + origin.y}, origin, cos_angle, sin_angle)
		new_center := apply_transform(transform, {half_width, half_height})
		center_x = new_center.x
		center_y = new_center.y
	}

	bounds_half_width, bounds_half_height := half_width, half_height
	if has_rotation {
		expanded := rotated_aabb_half_extents(half_width, half_height, cos_angle, sin_angle)
		bounds_half_width = expanded.x
		bounds_half_height = expanded.y
	}

	prim := Core_2D_Primitive {
		bounds      = {
			center_x - bounds_half_width - padding,
			center_y - bounds_half_height - padding,
			center_x + bounds_half_width + padding,
			center_y + bounds_half_height + padding,
		},
		rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0,
	}
	prim.params.rrect = RRect_Params {
		half_size    = {half_width * dpi_scale, half_height * dpi_scale},
		radii        = {
			clamped_bottom_right * dpi_scale,
			clamped_top_right * dpi_scale,
			clamped_bottom_left * dpi_scale,
			clamped_top_left * dpi_scale,
		},
		half_feather = half_feather,
	}
	return prim
}

// Build an RRect Core_2D_Primitive for a circle (fully-rounded square RRect).
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_circle_primitive :: proc(
	center: Vec2,
	radius: f32,
	origin: Vec2,
	rotation: f32,
	feather_px: f32,
) -> Core_2D_Primitive {
	half_feather := feather_px * 0.5
	padding := half_feather / GLOB.dpi_scaling
	dpi_scale := GLOB.dpi_scaling

	actual_center := center
	if origin != {0, 0} {
		sin_a, cos_a := math.sincos(math.to_radians(rotation))
		actual_center = compute_pivot_center(center, origin, sin_a, cos_a)
	}

	prim := Core_2D_Primitive {
		bounds = {
			actual_center.x - radius - padding,
			actual_center.y - radius - padding,
			actual_center.x + radius + padding,
			actual_center.y + radius + padding,
		},
	}
	scaled_radius := radius * dpi_scale
	prim.params.rrect = RRect_Params {
		half_size    = {scaled_radius, scaled_radius},
		radii        = {scaled_radius, scaled_radius, scaled_radius, scaled_radius},
		half_feather = half_feather,
	}
	return prim
}

// Build an Ellipse Core_2D_Primitive with bounds, params, and rotation computed from ellipse geometry.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_ellipse_primitive :: proc(
	center: Vec2,
	radius_horizontal, radius_vertical: f32,
	origin: Vec2,
	rotation: f32,
	feather_px: f32,
) -> Core_2D_Primitive {
	half_feather := feather_px * 0.5
	padding := half_feather / GLOB.dpi_scaling
	dpi_scale := GLOB.dpi_scaling

	actual_center := center
	sin_angle: f32 = 0
	cos_angle: f32 = 1
	has_rotation := false

	if needs_transform(origin, rotation) {
		rotation_radians := math.to_radians(rotation)
		sin_angle, cos_angle = math.sincos(rotation_radians)
		actual_center = compute_pivot_center(center, origin, sin_angle, cos_angle)
		has_rotation = rotation != 0
	}

	bound_horizontal, bound_vertical := radius_horizontal, radius_vertical
	if has_rotation {
		expanded := rotated_aabb_half_extents(radius_horizontal, radius_vertical, cos_angle, sin_angle)
		bound_horizontal = expanded.x
		bound_vertical = expanded.y
	}

	prim := Core_2D_Primitive {
		bounds      = {
			actual_center.x - bound_horizontal - padding,
			actual_center.y - bound_vertical - padding,
			actual_center.x + bound_horizontal + padding,
			actual_center.y + bound_vertical + padding,
		},
		rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0,
	}
	prim.params.ellipse = Ellipse_Params {
		radii        = {radius_horizontal * dpi_scale, radius_vertical * dpi_scale},
		half_feather = half_feather,
	}
	return prim
}

// Build an NGon Core_2D_Primitive with bounds, params, and rotation computed from polygon geometry.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_polygon_primitive :: proc(
	center: Vec2,
	sides: int,
	radius: f32,
	origin: Vec2,
	rotation: f32,
	feather_px: f32,
) -> Core_2D_Primitive {
	half_feather := feather_px * 0.5
	padding := half_feather / GLOB.dpi_scaling
	dpi_scale := GLOB.dpi_scaling

	actual_center := center
	if origin != {0, 0} && rotation != 0 {
		sin_a, cos_a := math.sincos(math.to_radians(rotation))
		actual_center = compute_pivot_center(center, origin, sin_a, cos_a)
	}

	rotation_radians := math.to_radians(rotation)
	sin_rot, cos_rot := math.sincos(rotation_radians)

	prim := Core_2D_Primitive {
		bounds      = {
			actual_center.x - radius - padding,
			actual_center.y - radius - padding,
			actual_center.x + radius + padding,
			actual_center.y + radius + padding,
		},
		rotation_sc = rotation != 0 ? pack_rotation_sc(sin_rot, cos_rot) : 0,
	}
	prim.params.ngon = NGon_Params {
		radius       = radius * math.cos(math.PI / f32(sides)) * dpi_scale,
		sides        = f32(sides),
		half_feather = half_feather,
	}
	return prim
}

// Build a Ring_Arc Core_2D_Primitive with bounds and params computed from ring/arc geometry.
// Pre-computes the angular boundary normals on the CPU so the fragment shader needs
// no per-pixel sin/cos. The radial SDF uses max(inner-r, r-outer) which correctly
// handles pie slices (inner_radius = 0) and full rings.
// The caller sets color, flags, and uv fields on the returned primitive before submitting.
//INTERNAL
build_ring_arc_primitive :: proc(
	center: Vec2,
	inner_radius, outer_radius: f32,
	start_angle: f32,
	end_angle: f32,
	origin: Vec2,
	rotation: f32,
	feather_px: f32,
) -> (
	Core_2D_Primitive,
	Shape_Flags,
) {
	half_feather := feather_px * 0.5
	padding := half_feather / GLOB.dpi_scaling
	dpi_scale := GLOB.dpi_scaling

	actual_center := center
	rotation_offset: f32 = 0
	if needs_transform(origin, rotation) {
		sin_a, cos_a := math.sincos(math.to_radians(rotation))
		actual_center = compute_pivot_center(center, origin, sin_a, cos_a)
		rotation_offset = math.to_radians(rotation)
	}

	start_rad := math.to_radians(start_angle) + rotation_offset
	end_rad := math.to_radians(end_angle) + rotation_offset

	// Normalize arc span to [0, 2π]
	arc_span := end_rad - start_rad
	if arc_span < 0 {
		arc_span += 2 * math.PI
	}

	// Pre-compute edge normals and arc flags on CPU — no per-pixel trig needed.
	// arc_flags: {} = full ring, {.Arc_Narrow} = span ≤ π (intersect), {.Arc_Wide} = span > π (union)
	arc_flags: Shape_Flags = {}
	normal_start: [2]f32 = {}
	normal_end: [2]f32 = {}

	if arc_span < 2 * math.PI - 0.001 {
		sin_start, cos_start := math.sincos(start_rad)
		sin_end, cos_end := math.sincos(end_rad)
		normal_start = {sin_start, -cos_start}
		normal_end = {-sin_end, cos_end}
		arc_flags = arc_span <= math.PI ? {.Arc_Narrow} : {.Arc_Wide}
	}

	prim := Core_2D_Primitive {
		bounds = {
			actual_center.x - outer_radius - padding,
			actual_center.y - outer_radius - padding,
			actual_center.x + outer_radius + padding,
			actual_center.y + outer_radius + padding,
		},
	}
	prim.params.ring_arc = Ring_Arc_Params {
		inner_radius = inner_radius * dpi_scale,
		outer_radius = outer_radius * dpi_scale,
		normal_start = normal_start,
		normal_end   = normal_end,
		half_feather = half_feather,
	}
	return prim, arc_flags
}

//----- Brush and outline ----------------------------------

// Apply brush fill and outline to a primitive, then submit it.
// Dispatches to the correct sub-batch based on the Brush variant.
// All parameters (outline_width) are in logical pixels, matching the rest of the public API.
// The helper converts to physical pixels for GPU packing internally.
//INTERNAL
apply_brush_and_outline :: proc(
	layer: ^Layer,
	prim: ^Core_2D_Primitive,
	kind: Shape_Kind,
	brush: Brush,
	outline_color: Color,
	outline_width: f32,
	extra_flags: Shape_Flags = {},
) {
	flags: Shape_Flags = extra_flags

	// Fill — determined by the Brush variant.
	texture_id := INVALID_TEXTURE
	sampler := DFT_SAMPLER

	switch b in brush {
	case Color: prim.color = b
	case Linear_Gradient:
		flags += {.Gradient}
		prim.color = b.start_color
		prim.effects.gradient_color = b.end_color
		rad := math.to_radians(b.angle)
		sin_a, cos_a := math.sincos(rad)
		prim.effects.gradient_dir_sc = pack_f16_pair(f16(cos_a), f16(sin_a))
	case Radial_Gradient:
		flags += {.Gradient, .Gradient_Radial}
		prim.color = b.inner_color
		prim.effects.gradient_color = b.outer_color
	case Texture_Fill:
		flags += {.Textured}
		tint, uv, sam := resolve_texture_defaults(b)
		prim.color = tint
		prim.uv_rect = {uv.x, uv.y, uv.width, uv.height}
		texture_id = b.id
		sampler = sam
	}

	// Outline — orthogonal to all Brush variants.
	if outline_width > 0 {
		flags += {.Outline}
		prim.effects.outline_color = outline_color
		prim.effects.outline_packed = pack_f16_pair(f16(outline_width * GLOB.dpi_scaling), 0)
		// Expand bounds to contain the outline (bounds are in logical pixels)
		prim.bounds[0] -= outline_width
		prim.bounds[1] -= outline_width
		prim.bounds[2] += outline_width
		prim.bounds[3] += outline_width
	}

	// Set .Rotated flag if rotation_sc was populated by the build proc
	if prim.rotation_sc != 0 {
		flags += {.Rotated}
	}

	prim.flags = pack_kind_flags(kind, flags)
	prepare_sdf_primitive_ex(layer, prim^, texture_id, sampler)
}

// ---------------------------------------------------------------------------------------------------------------------
// ----- Public draw procs ------------
// ---------------------------------------------------------------------------------------------------------------------

// Draw a filled rectangle via SDF with optional per-corner rounding radii.
// Use `uniform_radii(rect, roundness)` to compute uniform radii from a 0–1 fraction.
//
// Origin semantics:
//   `origin` is a local offset from the rect's top-left corner that selects both the positioning
//   anchor and the rotation pivot. `rect.x, rect.y` specifies where that anchor point lands in
//   world space. When `origin = {0, 0}` (default), `rect.x, rect.y` is the top-left corner.
//   Rotation always occurs around the anchor point.
rectangle :: proc(
	layer: ^Layer,
	rect: Rectangle,
	brush: Brush,
	outline_color: Color = {},
	outline_width: f32 = 0,
	radii: Rectangle_Radii = {},
	origin: Vec2 = {},
	rotation: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px)
	apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}

// Draw a filled circle via SDF (emitted as a fully-rounded RRect).
//
// Origin semantics (Convention B):
//   `origin` is a local offset from the shape's center that selects both the positioning anchor
//   and the rotation pivot. The `center` parameter specifies where that anchor point lands in
//   world space. When `origin = {0, 0}` (default), `center` is the visual center.
//   When `origin = {r, 0}`, the point `r` pixels to the right of the shape center lands at
//   `center`, shifting the shape left by `r`.
circle :: proc(
	layer: ^Layer,
	center: Vec2,
	radius: f32,
	brush: Brush,
	outline_color: Color = {},
	outline_width: f32 = 0,
	origin: Vec2 = {},
	rotation: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	prim := build_circle_primitive(center, radius, origin, rotation, feather_px)
	apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}

// Draw a filled ellipse via SDF.
// Origin semantics: see `circle`.
ellipse :: proc(
	layer: ^Layer,
	center: Vec2,
	radius_horizontal, radius_vertical: f32,
	brush: Brush,
	outline_color: Color = {},
	outline_width: f32 = 0,
	origin: Vec2 = {},
	rotation: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_px)
	apply_brush_and_outline(layer, &prim, .Ellipse, brush, outline_color, outline_width)
}

// Draw a filled regular polygon via SDF.
// `sides` must be >= 3. The polygon is inscribed in a circle of the given `radius`.
// Origin semantics: see `circle`.
polygon :: proc(
	layer: ^Layer,
	center: Vec2,
	sides: int,
	radius: f32,
	brush: Brush,
	outline_color: Color = {},
	outline_width: f32 = 0,
	origin: Vec2 = {},
	rotation: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	if sides < 3 do return

	prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_px)
	apply_brush_and_outline(layer, &prim, .NGon, brush, outline_color, outline_width)
}

// Draw a ring, arc, or pie slice via SDF.
// Full ring by default. Pass start_angle/end_angle (degrees) for partial arcs.
// Use inner_radius = 0 for pie slices (sectors).
// Origin semantics: see `circle`.
ring :: proc(
	layer: ^Layer,
	center: Vec2,
	inner_radius, outer_radius: f32,
	brush: Brush,
	outline_color: Color = {},
	outline_width: f32 = 0,
	start_angle: f32 = 0,
	end_angle: f32 = DFT_CIRC_END_ANGLE,
	origin: Vec2 = {},
	rotation: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	prim, arc_flags := build_ring_arc_primitive(
		center,
		inner_radius,
		outer_radius,
		start_angle,
		end_angle,
		origin,
		rotation,
		feather_px,
	)
	apply_brush_and_outline(layer, &prim, .Ring_Arc, brush, outline_color, outline_width, arc_flags)
}

// Draw a line segment via SDF (emitted as a rotated capsule-shaped RRect).
// Round caps are produced by setting corner radii equal to half the thickness.
line :: proc(
	layer: ^Layer,
	start_position, end_position: Vec2,
	brush: Brush,
	thickness: f32 = DFT_STROKE_THICKNESS,
	outline_color: Color = {},
	outline_width: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	delta_x := end_position.x - start_position.x
	delta_y := end_position.y - start_position.y
	seg_length := math.sqrt(delta_x * delta_x + delta_y * delta_y)
	if seg_length < 0.0001 do return
	rotation_radians := math.atan2(delta_y, delta_x)
	sin_angle, cos_angle := math.sincos(rotation_radians)

	center_x := (start_position.x + end_position.x) * 0.5
	center_y := (start_position.y + end_position.y) * 0.5

	half_length := seg_length * 0.5
	half_thickness := thickness * 0.5
	cap_radius := half_thickness

	half_feather := feather_px * 0.5
	padding := half_feather / GLOB.dpi_scaling
	dpi_scale := GLOB.dpi_scaling

	// Expand bounds for rotation
	bounds_half := rotated_aabb_half_extents(half_length + cap_radius, half_thickness, cos_angle, sin_angle)

	prim := Core_2D_Primitive {
		bounds      = {
			center_x - bounds_half.x - padding,
			center_y - bounds_half.y - padding,
			center_x + bounds_half.x + padding,
			center_y + bounds_half.y + padding,
		},
		rotation_sc = pack_rotation_sc(sin_angle, cos_angle),
	}
	prim.params.rrect = RRect_Params {
		half_size    = {(half_length + cap_radius) * dpi_scale, half_thickness * dpi_scale},
		radii        = {
			cap_radius * dpi_scale,
			cap_radius * dpi_scale,
			cap_radius * dpi_scale,
			cap_radius * dpi_scale,
		},
		half_feather = half_feather,
	}
	apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}

// Draw a line strip via decomposed SDF line segments.
line_strip :: proc(
	layer: ^Layer,
	points: []Vec2,
	brush: Brush,
	thickness: f32 = DFT_STROKE_THICKNESS,
	outline_color: Color = {},
	outline_width: f32 = 0,
	feather_px: f32 = DFT_FEATHER_PX,
) {
	if len(points) < 2 do return
	for i in 0 ..< len(points) - 1 {
		line(layer, points[i], points[i + 1], brush, thickness, outline_color, outline_width, feather_px)
	}
}
