feat(core): add bezier_editor — visual cubic Bezier curve editor

ImGui canvas with 4 draggable control points (p0/p3 locked at (0,0)/(1,1)
by default for use as easing curves). Pure evaluation via De Casteljau
(bezier_point) plus sampling-based y(x) lookup (bezier_eval).

Render uses fn_tokens for visual coherence: bg, border, primary curve,
text_dim diagonal reference, text_muted handle tangents.

p1.x and p2.x clamped to [0,1] to keep the curve usable as easing; Y
values are free to allow deliberate overshoot.
This commit is contained in:
2026-04-25 21:50:29 +02:00
parent 76215765a7
commit b9810a88d4
3 changed files with 347 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
#include "core/bezier_editor.h"
#include "core/tokens.h"
#include <imgui.h>
#include <imgui_internal.h>
#include <cmath>
#include <cstdio>
namespace fn {
// ---------------------------------------------------------------------------
// Pure evaluation
// ---------------------------------------------------------------------------
ImVec2 bezier_point(const BezierCurve& c, float u) {
// De Casteljau cubica: 3 niveles de lerp.
auto lerp2 = [](ImVec2 a, ImVec2 b, float t) {
return ImVec2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
};
ImVec2 q0 = lerp2(c.p0, c.p1, u);
ImVec2 q1 = lerp2(c.p1, c.p2, u);
ImVec2 q2 = lerp2(c.p2, c.p3, u);
ImVec2 r0 = lerp2(q0, q1, u);
ImVec2 r1 = lerp2(q1, q2, u);
return lerp2(r0, r1, u);
}
float bezier_eval(const BezierCurve& c, float t) {
// Approxima y(x=t) muestreando la curva en N puntos uniformes en u y
// buscando el segmento donde la X cae cerca de t. Suficiente para
// easing curves (monotonas en X por construccion).
if (t <= 0.0f) return c.p0.y;
if (t >= 1.0f) return c.p3.y;
constexpr int N = 64;
ImVec2 prev = c.p0;
for (int i = 1; i <= N; i++) {
float u = (float)i / (float)N;
ImVec2 cur = bezier_point(c, u);
if (cur.x >= t) {
// Interp lineal en X dentro del segmento [prev, cur].
float dx = cur.x - prev.x;
float k = (dx > 1e-6f) ? (t - prev.x) / dx : 0.0f;
return prev.y + (cur.y - prev.y) * k;
}
prev = cur;
}
return c.p3.y;
}
// ---------------------------------------------------------------------------
// Editor widget
// ---------------------------------------------------------------------------
namespace {
// Convierte coords espacio-curva [0..1] a pixel-space del canvas.
ImVec2 to_canvas(const ImVec2& p, const ImVec2& canvas_min, const ImVec2& canvas_size) {
// Y se invierte porque ImGui crece hacia abajo.
return ImVec2(canvas_min.x + p.x * canvas_size.x,
canvas_min.y + (1.0f - p.y) * canvas_size.y);
}
// Inverso: pixel-space -> espacio-curva.
ImVec2 to_curve(const ImVec2& p, const ImVec2& canvas_min, const ImVec2& canvas_size) {
float fx = (p.x - canvas_min.x) / canvas_size.x;
float fy = 1.0f - (p.y - canvas_min.y) / canvas_size.y;
return ImVec2(fx, fy);
}
// Drag handle invisible centrado en `pos` con radio `r`. Devuelve true si se
// arrastro este frame; en ese caso `out` contiene la nueva pos en espacio
// curva.
bool drag_handle(const char* id,
const ImVec2& pos,
const ImVec2& canvas_min,
const ImVec2& canvas_size,
float r,
ImVec2& out)
{
ImVec2 px = to_canvas(pos, canvas_min, canvas_size);
ImGui::SetCursorScreenPos(ImVec2(px.x - r, px.y - r));
ImGui::InvisibleButton(id, ImVec2(r * 2.0f, r * 2.0f));
bool changed = false;
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
ImVec2 mouse = ImGui::GetIO().MousePos;
out = to_curve(mouse, canvas_min, canvas_size);
changed = true;
}
return changed;
}
} // namespace
bool bezier_editor(const char* id, BezierCurve& curve, ImVec2 size, bool lock_endpoints) {
using namespace fn_tokens;
ImGui::PushID(id);
// Reservar canvas
ImVec2 canvas_min = ImGui::GetCursorScreenPos();
ImVec2 canvas_size = size;
if (canvas_size.x <= 0.0f) canvas_size.x = ImGui::GetContentRegionAvail().x;
if (canvas_size.y <= 0.0f) canvas_size.y = 200.0f;
ImVec2 canvas_max = ImVec2(canvas_min.x + canvas_size.x,
canvas_min.y + canvas_size.y);
ImDrawList* dl = ImGui::GetWindowDrawList();
// Background + border
dl->AddRectFilled(canvas_min, canvas_max, ImGui::GetColorU32(colors::bg), radius::sm);
dl->AddRect(canvas_min, canvas_max, ImGui::GetColorU32(colors::border), radius::sm);
// Grid sutil (4x4)
ImU32 grid_col = ImGui::GetColorU32(colors::border);
for (int i = 1; i < 4; i++) {
float fx = canvas_min.x + canvas_size.x * (float)i / 4.0f;
float fy = canvas_min.y + canvas_size.y * (float)i / 4.0f;
dl->AddLine(ImVec2(fx, canvas_min.y), ImVec2(fx, canvas_max.y), grid_col);
dl->AddLine(ImVec2(canvas_min.x, fy), ImVec2(canvas_max.x, fy), grid_col);
}
// Diagonal de referencia (linear)
ImU32 diag_col = ImGui::GetColorU32(colors::text_dim);
dl->AddLine(to_canvas({0,0}, canvas_min, canvas_size),
to_canvas({1,1}, canvas_min, canvas_size), diag_col, 1.0f);
// Lineas tangentes p0->p1 y p3->p2
ImU32 tang_col = ImGui::GetColorU32(colors::text_muted);
dl->AddLine(to_canvas(curve.p0, canvas_min, canvas_size),
to_canvas(curve.p1, canvas_min, canvas_size), tang_col, 1.0f);
dl->AddLine(to_canvas(curve.p3, canvas_min, canvas_size),
to_canvas(curve.p2, canvas_min, canvas_size), tang_col, 1.0f);
// La curva Bezier
ImU32 curve_col = ImGui::GetColorU32(colors::primary);
dl->AddBezierCubic(
to_canvas(curve.p0, canvas_min, canvas_size),
to_canvas(curve.p1, canvas_min, canvas_size),
to_canvas(curve.p2, canvas_min, canvas_size),
to_canvas(curve.p3, canvas_min, canvas_size),
curve_col, 2.0f, 0);
// Handles
ImU32 handle_col = ImGui::GetColorU32(colors::primary);
ImU32 handle_locked = ImGui::GetColorU32(colors::text_dim);
constexpr float r = 6.0f;
bool changed = false;
ImVec2 np;
// p0
if (lock_endpoints) {
ImVec2 px = to_canvas(curve.p0, canvas_min, canvas_size);
dl->AddCircleFilled(px, r, handle_locked);
} else {
if (drag_handle("##p0", curve.p0, canvas_min, canvas_size, r, np)) {
curve.p0 = np; changed = true;
}
ImVec2 px = to_canvas(curve.p0, canvas_min, canvas_size);
dl->AddCircleFilled(px, r, handle_col);
}
// p3
if (lock_endpoints) {
ImVec2 px = to_canvas(curve.p3, canvas_min, canvas_size);
dl->AddCircleFilled(px, r, handle_locked);
} else {
if (drag_handle("##p3", curve.p3, canvas_min, canvas_size, r, np)) {
curve.p3 = np; changed = true;
}
ImVec2 px = to_canvas(curve.p3, canvas_min, canvas_size);
dl->AddCircleFilled(px, r, handle_col);
}
// p1 (draggable, sin clamp para permitir overshoot)
if (drag_handle("##p1", curve.p1, canvas_min, canvas_size, r, np)) {
// Clamp solo X a [0,1] para mantener monotonia razonable; Y libre.
np.x = (np.x < 0.0f) ? 0.0f : (np.x > 1.0f ? 1.0f : np.x);
curve.p1 = np; changed = true;
}
{
ImVec2 px = to_canvas(curve.p1, canvas_min, canvas_size);
dl->AddCircleFilled(px, r, handle_col);
}
// p2
if (drag_handle("##p2", curve.p2, canvas_min, canvas_size, r, np)) {
np.x = (np.x < 0.0f) ? 0.0f : (np.x > 1.0f ? 1.0f : np.x);
curve.p2 = np; changed = true;
}
{
ImVec2 px = to_canvas(curve.p2, canvas_min, canvas_size);
dl->AddCircleFilled(px, r, handle_col);
}
// Avanzar el cursor por debajo del canvas para que el siguiente widget no
// se solape (los InvisibleButton anteriores movieron el cursor).
ImGui::SetCursorScreenPos(ImVec2(canvas_min.x, canvas_max.y + spacing::xs));
ImGui::Dummy(ImVec2(canvas_size.x, 1.0f));
ImGui::PopID();
return changed;
}
} // namespace fn
+49
View File
@@ -0,0 +1,49 @@
#pragma once
// bezier_editor — editor visual de una curva Bezier cubica (4 puntos de
// control) usado tipicamente como diseñador de easing custom.
//
// Estado puro (struct BezierCurve), evaluacion algebraica (bezier_eval),
// editor en canvas ImGui con 4 puntos draggable (p0/p3 fijos por defecto).
//
// Uso:
// static fn::BezierCurve curve; // identidad: linear
// if (fn::bezier_editor("##my_ease", curve)) { /* curva cambio */ }
// float y = fn::bezier_eval(curve, t);
#include <imgui.h>
namespace fn {
// Curva Bezier cubica: 4 puntos de control en espacio [0,1]x[0,1].
// Por convencion, para easing curves: p0 = (0,0) y p3 = (1,1) (fijos).
// p1, p2 son los handles que el usuario arrastra.
struct BezierCurve {
ImVec2 p0 {0.0f, 0.0f};
ImVec2 p1 {0.25f, 0.0f};
ImVec2 p2 {0.75f, 1.0f};
ImVec2 p3 {1.0f, 1.0f};
};
// Evaluacion puramente algebraica via De Casteljau de la coordenada Y de la
// curva en el parametro de curva u in [0,1].
//
// NOTA: esto NO es y(x). Para una curva tipo easing donde queremos y dado un
// x temporal usamos bezier_eval (ver mas abajo) que aproxima y al x deseado
// asumiendo que la curva es monotona en X (lo es si p1.x, p2.x in [0,1] y la
// curva no cruza x=0 o x=1 fuera de los extremos).
ImVec2 bezier_point(const BezierCurve& c, float u);
// y at x=t — aproximacion mediante sampling + interpolacion lineal entre
// muestras. Suficiente para easing curves (la curva es casi monotona en X
// por construccion). Pure.
float bezier_eval(const BezierCurve& c, float t);
// Editor visual: canvas con 4 puntos draggable. p0 y p3 estan fijos por
// defecto (lock_endpoints=true) para que la curva sirva como easing
// (f(0)=0, f(1)=1). p1 y p2 son draggable libremente.
//
// Devuelve true en el frame en que el usuario arrastro algun punto.
bool bezier_editor(const char* id, BezierCurve& curve, ImVec2 size = ImVec2(200, 200), bool lock_endpoints = true);
} // namespace fn
+94
View File
@@ -0,0 +1,94 @@
---
name: bezier_editor
kind: component
lang: cpp
domain: core
version: "1.0.0"
purity: pure
signature: "bool fn::bezier_editor(const char* id, fn::BezierCurve& curve, ImVec2 size = {200,200}, bool lock_endpoints = true) + float fn::bezier_eval(const BezierCurve&, float t)"
description: "Editor visual de una curva Bezier cubica (4 puntos de control). Permite diseñar easing curves custom arrastrando p1 y p2 (p0 y p3 fijos en (0,0) y (1,1)). Evaluacion via De Casteljau + sampling. Render en canvas ImGui usando tokens (primary, surface, border)."
tags: [imgui, bezier, animation, easing, editor, canvas]
uses_functions:
- tokens_cpp_core
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: [imgui]
tested: false
tests: []
test_file_path: ""
file_path: "cpp/functions/core/bezier_editor.cpp"
framework: imgui
params:
- name: id
desc: "ID ImGui unico (formato '##nombre' tipico para no mostrar texto)"
- name: curve
desc: "Estado de la curva (struct BezierCurve con p0..p3 en [0,1]x[0,1]). Modificada por el widget."
- name: size
desc: "Tamaño del canvas en pixels. Recomendado >= 180x180 para precision de drag."
- name: lock_endpoints
desc: "Si true (default) p0 y p3 quedan fijos en (0,0) y (1,1) — uso como easing curve. Si false, p0/p3 son draggable."
output: "true en el frame en que el usuario arrastro algun punto de control"
---
# bezier_editor
Editor inline en canvas ImGui para diseñar curvas Bezier cubicas. La aplicacion tipica es disenar **easing curves custom** que no encajan con las preset de `tween_curves` (Penner). Tambien sirve para definir ramps de color, perfiles de velocidad, etc.
## Estado y evaluacion (puro)
```cpp
struct fn::BezierCurve {
ImVec2 p0 {0,0};
ImVec2 p1 {0.25f, 0.0f};
ImVec2 p2 {0.75f, 1.0f};
ImVec2 p3 {1,1};
};
ImVec2 fn::bezier_point(const BezierCurve&, float u); // De Casteljau, devuelve (x,y) en u
float fn::bezier_eval (const BezierCurve&, float t); // y at x=t (sampling 64 + interp lineal)
```
`bezier_eval` asume monotonia en X (caso tipico de easing). Para curvas con overshoot horizontal el resultado puede saltar — no es un bug, es una limitacion del modelo de easing-curve.
## Render
```cpp
static fn::BezierCurve curve; // identidad lineal por defecto
if (fn::bezier_editor("##my_ease", curve, ImVec2(220, 220))) {
// El usuario movio un punto este frame; recomputar lo que dependa.
}
float y = fn::bezier_eval(curve, t);
```
## Visuales
- Fondo `bg`, borde `border`, grid 4x4 con `border` (subtle).
- Diagonal de referencia (linear) con `text_dim`.
- Lineas tangentes p0->p1 y p3->p2 con `text_muted`.
- Curva con `primary` y grosor 2.
- Handles: circulos `primary` para draggable, `text_dim` para los lockeados.
## Interaccion
- Drag de p1 / p2 con boton izquierdo del raton.
- Si `lock_endpoints=false`, p0 y p3 tambien arrastrables.
- p1.x y p2.x se clampan a [0,1]; las Y son libres (permiten overshoot deliberado).
- Tamaño minimo recomendado **180x180**: por debajo el drag se vuelve impreciso.
## Tests conocidos
- Curva identidad `{(0,0),(0.33,0.33),(0.66,0.66),(1,1)}``bezier_eval(c, 0.5) ~= 0.5` (tolerancia ~0.01 por sampling).
- `bezier_eval(c, 0.0) == 0.0` y `bezier_eval(c, 1.0) == 1.0` para cualquier curva con `lock_endpoints=true`.
## Decisiones
- **Sampling 64 + lerp** para `bezier_eval` en vez de inversion analitica (Cardano): mas simple, rapido, suficiente para easing.
- **Sin guardar curva en estado interno**: el caller posee `BezierCurve` (igual que el resto de primitivos del registry).
- **Endpoints lockeados por defecto**: el caso 99% de uso (easing) ya define p0=(0,0), p3=(1,1).
## Notas
- No hay snap a grid en el MVP. Si hace falta, exponer un parametro adicional o usar Shift+drag.
- El editor consume todo el ancho de la celda padre si `size.x = 0` (auto).