From a9045d45a0a538e9d59ef4dea46eca5b5ce09f8f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:26:03 +0200 Subject: [PATCH] feat(cpp/gfx): code_to_generator + shaderlab_db Dos primitivas del pipeline de shaders_lab que ya estaban en uso pero sin indexar al registry: - code_to_generator_cpp_gfx (function, pure) Traduce un fragment shader GLSL escrito a mano (modo Code, con void main() + fragColor = ...) en un body de DAG Gen + DagControl[]. Cada uniform anotado se convierte en un control; el body usa el parametro uv y reemplaza fragColor= por return. Empaqueta uniforms en vec4 (4 x n_uniforms). - shaderlab_db_cpp_gfx (function, impure) CRUD persistente para generators custom de shaders_lab via sqlite3. Guarda el GLSL original, el body traducido para el DAG, los DagControl y los param_defaults en una BD local (shaders_lab.db). Soporta open(:memory:) para tests. Ambas se indexan ahora en registry.db y son reusables fuera de shaders_lab si en el futuro hay otra app que componga DAGs de shaders. --- cpp/functions/gfx/code_to_generator.cpp | 400 +++++++++++++++++++++++ cpp/functions/gfx/code_to_generator.h | 39 +++ cpp/functions/gfx/code_to_generator.md | 58 ++++ cpp/functions/gfx/shaderlab_db.cpp | 401 ++++++++++++++++++++++++ cpp/functions/gfx/shaderlab_db.h | 50 +++ cpp/functions/gfx/shaderlab_db.md | 64 ++++ 6 files changed, 1012 insertions(+) create mode 100644 cpp/functions/gfx/code_to_generator.cpp create mode 100644 cpp/functions/gfx/code_to_generator.h create mode 100644 cpp/functions/gfx/code_to_generator.md create mode 100644 cpp/functions/gfx/shaderlab_db.cpp create mode 100644 cpp/functions/gfx/shaderlab_db.h create mode 100644 cpp/functions/gfx/shaderlab_db.md diff --git a/cpp/functions/gfx/code_to_generator.cpp b/cpp/functions/gfx/code_to_generator.cpp new file mode 100644 index 00000000..17825c00 --- /dev/null +++ b/cpp/functions/gfx/code_to_generator.cpp @@ -0,0 +1,400 @@ +#include "gfx/code_to_generator.h" +#include "gfx/uniform_parser.h" +#include +#include +#include + +namespace fn::gfx { + +// Replace every occurrence of `from` with `to` in `s`. +static void replace_all(std::string& s, const std::string& from, const std::string& to) { + if (from.empty()) return; + size_t p = 0; + while ((p = s.find(from, p)) != std::string::npos) { + s.replace(p, from.size(), to); + p += to.size(); + } +} + +// Extract the body of `void main() { ... }` (content between the outermost braces). +// Returns empty string if no balanced body is found. +static std::string extract_main_body(const std::string& source) { + static const std::regex main_re(R"(\bvoid\s+main\s*\(\s*\)\s*\{)"); + std::smatch m; + if (!std::regex_search(source, m, main_re)) return ""; + + size_t start = static_cast(m.position(0)) + m.length(0); // after the '{' + int depth = 1; + size_t end = start; + for (; end < source.size() && depth > 0; ++end) { + if (source[end] == '{') ++depth; + else if (source[end] == '}') --depth; + if (depth == 0) break; + } + if (depth != 0) return ""; + return source.substr(start, end - start); +} + +// Strip lines that match `vec2 uv = ;` (the function already provides uv). +static std::string strip_uv_decl(const std::string& body) { + static const std::regex uv_re(R"(^\s*vec2\s+uv\s*=\s*[^;]+;\s*$)"); + std::ostringstream out; + std::istringstream in(body); + std::string line; + bool first = true; + while (std::getline(in, line)) { + if (std::regex_match(line, uv_re)) continue; + if (!first) out << '\n'; + out << line; + first = false; + } + return out.str(); +} + +// Replace `fragColor = X;` with `return X;`. Tolerant to whitespace; one-shot regex. +static std::string fragcolor_to_return(const std::string& body) { + static const std::regex fc_re(R"(\bfragColor\s*=\s*([^;]+);)"); + return std::regex_replace(body, fc_re, "return $1;"); +} + +// GLSL type info for a uniform: alias declaration, control kind, slot count. +struct TypeInfo { + bool ok = true; + const char* alias_type = "float"; // GLSL keyword + const char* alias_swizzle = ".x"; // suffix on u_params[k] + DagControl::Kind ctrl_kind = DagControl::Kind::Slider; + int components = 1; // floats consumed +}; + +static TypeInfo type_info_for(GLSLType t, WidgetKind w) { + TypeInfo ti; + switch (t) { + case GLSLType::Float: + ti.alias_type = "float"; + ti.alias_swizzle = ".x"; + ti.ctrl_kind = DagControl::Kind::Slider; + ti.components = 1; + break; + case GLSLType::Int: + ti.alias_type = "int"; + ti.alias_swizzle = ".x"; + ti.ctrl_kind = DagControl::Kind::Slider; + ti.components = 1; + break; + case GLSLType::Bool: + ti.alias_type = "bool"; + ti.alias_swizzle = ".x"; + ti.ctrl_kind = DagControl::Kind::Slider; // 0/1 slider + ti.components = 1; + break; + case GLSLType::Vec2: + ti.alias_type = "vec2"; + ti.alias_swizzle = ".xy"; + ti.ctrl_kind = DagControl::Kind::XY; + ti.components = 2; + break; + case GLSLType::Vec3: + ti.alias_type = "vec3"; + ti.alias_swizzle = ".xyz"; + ti.ctrl_kind = DagControl::Kind::Color; + ti.components = 3; + break; + case GLSLType::Vec4: + // Treat as Color if widget says so, otherwise unsupported (KISS). + if (w == WidgetKind::Color) { + ti.alias_type = "vec4"; + ti.alias_swizzle = ""; + ti.ctrl_kind = DagControl::Kind::Color; + ti.components = 3; // we render only RGB; alpha sits unused + } else { + ti.ok = false; + } + break; + } + // Widget overrides (when annotated) + if (ti.ok) { + if (w == WidgetKind::Color && t != GLSLType::Vec3 && t != GLSLType::Vec4) { + ti.ctrl_kind = DagControl::Kind::Slider; // can't color a non-vec3/4 + } + if (w == WidgetKind::Toggle) { + ti.ctrl_kind = DagControl::Kind::Slider; + } + if (w == WidgetKind::XY && t == GLSLType::Vec2) { + ti.ctrl_kind = DagControl::Kind::XY; + } + } + return ti; +} + +// Special-case: a vec4 typed as Color has alias `.xyz` for the local declared as vec3? +// We keep the alias as the full vec4 to avoid changing the user's body, and treat the +// control as Color over the first 3 components. +static const char* alias_swizzle_for(GLSLType t) { + switch (t) { + case GLSLType::Float: + case GLSLType::Int: + case GLSLType::Bool: return ".x"; + case GLSLType::Vec2: return ".xy"; + case GLSLType::Vec3: return ".xyz"; + case GLSLType::Vec4: return ""; + } + return ""; +} + +CodeToGeneratorResult code_to_generator(const std::string& source) { + CodeToGeneratorResult r; + + auto descs = parse_uniforms(source); + + // Reject sampler2D and other unsupported forms by inspecting raw source. + // parse_uniforms already drops sampler2D silently; warn the user explicitly. + if (source.find("sampler2D") != std::string::npos) { + r.err = "sampler2D uniforms are not supported in saved generators"; + return r; + } + + if (descs.empty() && source.find("uniform") != std::string::npos) { + // Has uniforms but parser found none → unsupported types. + // (Could be more granular; for now just warn.) + } + + if (descs.size() > 16) { + r.err = "too many uniforms (max 16 per generator)"; + return r; + } + + std::string body_main = extract_main_body(source); + if (body_main.empty()) { + r.err = "could not find `void main() { ... }` body"; + return r; + } + if (body_main.find("fragColor") == std::string::npos) { + r.err = "body must assign `fragColor` at least once"; + return r; + } + + // Build aliases (preamble) and DagControls. + std::ostringstream pre; + int slot = 0; + for (const auto& d : descs) { + TypeInfo ti = type_info_for(d.glsl_type, d.widget); + if (!ti.ok) { + r.err = "uniform '" + d.name + "': vec4 only supported with @color"; + return r; + } + + // Local alias: float u_speed = u_params[__BASE__+slot].x; + pre << " " << ti.alias_type << ' ' << d.name + << " = " << ti.alias_type << "(u_params[__BASE__+" << slot << "]" + << alias_swizzle_for(d.glsl_type) << ");\n"; + + // Defaults: pack components into the 4 floats of this slot + const int base_float = slot * 4; + for (int c = 0; c < ti.components && c < 4; ++c) { + r.param_defaults.push_back(d.defaults[c]); + } + for (int c = ti.components; c < 4; ++c) { + r.param_defaults.push_back(0.0f); + } + // Param names: NAME, NAME.y, NAME.z... (for debug only) + r.param_names.push_back(d.name); + for (int c = 1; c < 4; ++c) r.param_names.push_back(""); + + // Build DagControl + DagControl ctrl; + ctrl.kind = ti.ctrl_kind; + ctrl.label = d.name; + ctrl.min = d.min[0]; + ctrl.max = d.max[0]; + ctrl.step = d.step; + if (ctrl.kind == DagControl::Kind::Slider) { + ctrl.param_idx = { base_float, -1, -1 }; + } else if (ctrl.kind == DagControl::Kind::XY) { + ctrl.param_idx = { base_float, base_float + 1, -1 }; + } else if (ctrl.kind == DagControl::Kind::Color) { + ctrl.param_idx = { base_float, base_float + 1, base_float + 2 }; + ctrl.min = 0.0f; + ctrl.max = 1.0f; + } + r.controls.push_back(std::move(ctrl)); + + ++slot; + } + + r.param_count = static_cast(r.param_defaults.size()); + + // Transform body: strip uv decl + fragColor → return. + std::string body = strip_uv_decl(body_main); + body = fragcolor_to_return(body); + + // Final body_template: preamble (aliases) + transformed body. + std::ostringstream tpl; + tpl << pre.str() << body; + r.body_template = tpl.str(); + r.ok = true; + return r; +} + +DagNodeDef make_generator_def(const std::string& name, + const std::string& label, + const std::string& desc, + const CodeToGeneratorResult& tr) { + DagNodeDef d; + d.name = name; + d.label = label.empty() ? name : label; + d.desc = desc; + d.kind = DagKind::Gen; + d.num_inputs = 0; + d.param_names = tr.param_names; + d.param_defaults = tr.param_defaults; + d.controls = tr.controls; + d.is_builtin = false; + + const std::string body_template = tr.body_template; + d.body_glsl = [body_template](int base) -> std::string { + std::string s = body_template; + replace_all(s, "__BASE__", std::to_string(base)); + return s; + }; + return d; +} + +} // namespace fn::gfx + +#ifdef CODE_TO_GENERATOR_TEST +#include +#include + +static bool contains(const std::string& hay, const std::string& needle) { + return hay.find(needle) != std::string::npos; +} + +int main() { + using namespace fn::gfx; + + // 1. Plasma-like Code: float speed + vec3 color → 2 controls (slider + color) + { + const char* src = R"glsl( +uniform float u_speed; // @slider min=0.1 max=5 default=1 +uniform vec3 u_color; // @color default=0.5,0.2,0.8 + +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution; + float t = u_time * u_speed; + vec3 c = u_color * (0.5 + 0.5 * cos(t + uv.xyx + vec3(0.0, 2.0, 4.0))); + fragColor = vec4(c, 1.0); +} +)glsl"; + auto r = code_to_generator(src); + assert(r.ok); + assert(r.controls.size() == 2); + assert(r.controls[0].kind == DagControl::Kind::Slider); + assert(r.controls[0].label == "u_speed"); + assert(r.controls[0].param_idx[0] == 0); + assert(r.controls[0].max == 5.0f); + assert(r.controls[1].kind == DagControl::Kind::Color); + assert(r.controls[1].label == "u_color"); + assert(r.controls[1].param_idx[0] == 4); + assert(r.controls[1].param_idx[2] == 6); + assert(r.param_count == 8); // 2 uniforms × 4 floats + assert(r.param_defaults.size() == 8); + assert(r.param_defaults[0] == 1.0f); // u_speed default + assert(r.param_defaults[4] == 0.5f); // u_color.r + assert(r.param_defaults[5] == 0.2f); + assert(r.param_defaults[6] == 0.8f); + + // body_template: should declare aliases referencing __BASE__ + assert(contains(r.body_template, "float u_speed")); + assert(contains(r.body_template, "u_params[__BASE__+0]")); + assert(contains(r.body_template, "vec3 u_color")); + assert(contains(r.body_template, "u_params[__BASE__+1]")); + // uv = ... line stripped + assert(!contains(r.body_template, "vec2 uv = gl_FragCoord")); + // fragColor → return + assert(contains(r.body_template, "return vec4(c, 1.0);")); + assert(!contains(r.body_template, "fragColor =")); + } + + // 2. make_generator_def + lambda substitution + { + const char* src = R"glsl( +uniform float u_x; // @slider min=0 max=1 default=0.5 +void main() { + fragColor = vec4(u_x, 0.0, 0.0, 1.0); +} +)glsl"; + auto tr = code_to_generator(src); + assert(tr.ok); + DagNodeDef def = make_generator_def("test_gen", "Test", "desc", tr); + assert(!def.is_builtin); + assert(def.kind == DagKind::Gen); + assert(def.num_inputs == 0); + std::string b3 = def.body_glsl(3); + assert(contains(b3, "u_params[3+0]")); + assert(!contains(b3, "__BASE__")); + std::string b0 = def.body_glsl(0); + assert(contains(b0, "u_params[0+0]")); + } + + // 3. Missing void main → error + { + auto r = code_to_generator("uniform float u_x;\n"); + assert(!r.ok); + assert(r.err.find("void main") != std::string::npos); + } + + // 4. No fragColor → error + { + auto r = code_to_generator("void main() { }\n"); + assert(!r.ok); + assert(r.err.find("fragColor") != std::string::npos); + } + + // 5. sampler2D rejected + { + const char* src = R"glsl( +uniform sampler2D u_tex; +void main() { fragColor = vec4(1.0); } +)glsl"; + auto r = code_to_generator(src); + assert(!r.ok); + assert(r.err.find("sampler2D") != std::string::npos); + } + + // 6. vec2 with @xy → XY control + { + const char* src = R"glsl( +uniform vec2 u_pos; // @xy min=-1 max=1 default=0,0 +void main() { fragColor = vec4(u_pos, 0.0, 1.0); } +)glsl"; + auto r = code_to_generator(src); + assert(r.ok); + assert(r.controls.size() == 1); + assert(r.controls[0].kind == DagControl::Kind::XY); + assert(r.controls[0].param_idx[0] == 0); + assert(r.controls[0].param_idx[1] == 1); + assert(r.controls[0].min == -1.0f); + assert(r.controls[0].max == 1.0f); + assert(contains(r.body_template, "vec2 u_pos")); + assert(contains(r.body_template, "u_params[__BASE__+0].xy")); + } + + // 7. No uniforms at all → empty controls, body_template still ok + { + const char* src = R"glsl( +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution; + fragColor = vec4(uv, 0.0, 1.0); +} +)glsl"; + auto r = code_to_generator(src); + assert(r.ok); + assert(r.controls.empty()); + assert(r.param_count == 0); + assert(contains(r.body_template, "return vec4(uv, 0.0, 1.0);")); + } + + std::printf("code_to_generator: 7/7 asserts passed\n"); + return 0; +} +#endif diff --git a/cpp/functions/gfx/code_to_generator.h b/cpp/functions/gfx/code_to_generator.h new file mode 100644 index 00000000..1d3e2fae --- /dev/null +++ b/cpp/functions/gfx/code_to_generator.h @@ -0,0 +1,39 @@ +#pragma once +#include "gfx/dag_types.h" +#include +#include + +namespace fn::gfx { + +// Result of translating a Code-mode GLSL fragment shader into a DAG generator +// body. `body_template` carries `__BASE__` placeholders that the body_glsl +// lambda substitutes for the node's vec4 base index at compile time. +struct CodeToGeneratorResult { + bool ok = false; + std::string err; + + std::string body_template; // body with __BASE__ tokens + int param_count = 0; // size of step.params (4 × n_uniforms) + std::vector param_defaults; + std::vector param_names; + std::vector controls; +}; + +// Translate a Code-mode GLSL source (with `void main()` + `fragColor = ...;`) +// into a DAG Gen body + controls. Each annotated uniform becomes a DagControl; +// the body uses `uv` from the function parameter (any local `vec2 uv = ...;` +// line is stripped). Returns ok=false with err set on parse / unsupported-type +// errors. Pure: no I/O. +// +// Layout: each uniform claims one vec4 (4 floats), wasting unused components +// to keep the float→vec4 mapping trivial. Total floats = 4 × num_uniforms. +CodeToGeneratorResult code_to_generator(const std::string& source); + +// Wrap a translation result into a DagNodeDef ready for dag_register_node(). +// kind = Gen, num_inputs = 0, is_builtin = false. +DagNodeDef make_generator_def(const std::string& name, + const std::string& label, + const std::string& desc, + const CodeToGeneratorResult& tr); + +} // namespace fn::gfx diff --git a/cpp/functions/gfx/code_to_generator.md b/cpp/functions/gfx/code_to_generator.md new file mode 100644 index 00000000..b69986ba --- /dev/null +++ b/cpp/functions/gfx/code_to_generator.md @@ -0,0 +1,58 @@ +--- +name: code_to_generator +kind: function +lang: cpp +domain: gfx +version: "1.0.0" +purity: pure +signature: "CodeToGeneratorResult code_to_generator(const std::string& source); DagNodeDef make_generator_def(const std::string& name, const std::string& label, const std::string& desc, const CodeToGeneratorResult& tr)" +description: "Traduce un fragment shader GLSL del modo Code (con `void main()` + `fragColor = ...`) en un body de DAG Gen + DagControl[]. Cada uniform anotado se convierte en un control; el body usa el parametro `uv` de la funcion (lineas `vec2 uv = ...;` se eliminan) y reemplaza `fragColor =` por `return`. Empaqueta cada uniform en su propio vec4 (parametros = 4 × n_uniforms)." +tags: [glsl, codegen, dag, generators, shaders_lab, parser, gfx] +uses_functions: + - uniform_parser_cpp_gfx +uses_types: + - dag_types_cpp_gfx +returns: [] +returns_optional: false +error_type: "" +imports: [code_to_generator, uniform_parser, dag_types, regex, sstream, string] +tested: true +tests: + - "plasma-like (float + vec3) → Slider + Color, defaults preservados, base placeholder" + - "make_generator_def substituye __BASE__ con el indice runtime" + - "missing void main → error" + - "missing fragColor → error" + - "sampler2D → error" + - "vec2 con @xy → XY control" + - "shader sin uniforms → controls vacios, body ok" +test_file_path: "cpp/functions/gfx/code_to_generator.cpp" +file_path: "cpp/functions/gfx/code_to_generator.cpp" +params: + - name: source + desc: "Fuente GLSL del modo Code: uniforms anotados (// @slider, @color, @xy, @toggle), `void main()` con `vec2 uv = gl_FragCoord.xy / u_resolution;` opcional y `fragColor = ...;`." + - name: name + desc: "Identificador snake_case del generator, unico en el catalogo (no debe colisionar con built-ins)." + - name: label + desc: "Etiqueta visible en la paleta. Si vacio, se usa name." + - name: desc + desc: "Descripcion libre del generator." + - name: tr + desc: "Resultado de `code_to_generator`. Solo se usa si `tr.ok == true`." +output: "CodeToGeneratorResult con body_template (con tokens __BASE__), param_count, param_defaults, param_names y DagControl[]. make_generator_def envuelve esto en un DagNodeDef listo para `dag_register_node()`." +--- + +## Layout de parametros + +Cada uniform reclama 1 vec4 entero, sin importar su tipo. Esto desperdicia hasta 3 floats por uniform pero hace trivial el mapeo: + +| GLSL type | Slot | Alias | Control | +|-----------|------|------------------------------------|---------| +| `float` | 1 | `float n = u_params[B+i].x;` | Slider | +| `int` | 1 | `int n = int(u_params[B+i].x);` | Slider | +| `bool` | 1 | `bool n = bool(u_params[B+i].x>0.5)` | Slider 0/1 | +| `vec2` | 1 | `vec2 n = u_params[B+i].xy;` | XY (con @xy) | +| `vec3` | 1 | `vec3 n = u_params[B+i].xyz;` | Color (con @color) | +| `vec4` | 1 | `vec4 n = u_params[B+i];` | Color (alpha en .w, sin control) — solo soportado con @color | +| `sampler2D` | - | - | error | + +`__BASE__` es el placeholder que `make_generator_def` sustituye por el `base_vec4` del nodo en runtime. diff --git a/cpp/functions/gfx/shaderlab_db.cpp b/cpp/functions/gfx/shaderlab_db.cpp new file mode 100644 index 00000000..c3632355 --- /dev/null +++ b/cpp/functions/gfx/shaderlab_db.cpp @@ -0,0 +1,401 @@ +#include "gfx/shaderlab_db.h" +#include +#include +#include +#include +#include +#include + +namespace fn::gfx { + +static sqlite3* g_db = nullptr; +static std::string g_path; + +static std::string now_iso() { + auto t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + std::tm tm_utc{}; +#if defined(_WIN32) + gmtime_s(&tm_utc, &t); +#else + gmtime_r(&t, &tm_utc); +#endif + std::ostringstream ss; + ss << std::put_time(&tm_utc, "%Y-%m-%dT%H:%M:%SZ"); + return ss.str(); +} + +// ── Serialization helpers (custom compact format, no JSON parser needed) ─── +// floats: "1.0,2.5,-0.3" +// strings: one per line, '\n' separator (labels may contain spaces but not LF) +// controls: one per line, fields separated by '|': +// kind(int)|label|p0|p1|p2|min|max|step + +static std::string floats_to_csv(const std::vector& v) { + std::ostringstream ss; + for (size_t i = 0; i < v.size(); ++i) { + if (i) ss << ','; + ss << v[i]; + } + return ss.str(); +} + +static std::vector floats_from_csv(const std::string& s) { + std::vector out; + if (s.empty()) return out; + std::istringstream ss(s); + std::string tok; + while (std::getline(ss, tok, ',')) { + try { out.push_back(std::stof(tok)); } catch (...) {} + } + return out; +} + +static std::string strings_to_lf(const std::vector& v) { + std::ostringstream ss; + for (size_t i = 0; i < v.size(); ++i) { + if (i) ss << '\n'; + ss << v[i]; + } + return ss.str(); +} + +static std::vector strings_from_lf(const std::string& s) { + std::vector out; + if (s.empty()) return out; + std::istringstream ss(s); + std::string line; + while (std::getline(ss, line)) out.push_back(line); + return out; +} + +static std::string controls_to_string(const std::vector& v) { + std::ostringstream ss; + for (size_t i = 0; i < v.size(); ++i) { + if (i) ss << '\n'; + ss << static_cast(v[i].kind) << '|' + << v[i].label << '|' + << v[i].param_idx[0] << '|' + << v[i].param_idx[1] << '|' + << v[i].param_idx[2] << '|' + << v[i].min << '|' + << v[i].max << '|' + << v[i].step; + } + return ss.str(); +} + +static std::vector controls_from_string(const std::string& s) { + std::vector out; + if (s.empty()) return out; + std::istringstream ss(s); + std::string line; + while (std::getline(ss, line)) { + DagControl c; + // Split by '|' into 8 fields + std::vector fields; + std::string buf; + for (char ch : line) { + if (ch == '|') { fields.push_back(buf); buf.clear(); } + else buf.push_back(ch); + } + fields.push_back(buf); + if (fields.size() < 8) continue; + try { + c.kind = static_cast(std::stoi(fields[0])); + c.label = fields[1]; + c.param_idx[0] = std::stoi(fields[2]); + c.param_idx[1] = std::stoi(fields[3]); + c.param_idx[2] = std::stoi(fields[4]); + c.min = std::stof(fields[5]); + c.max = std::stof(fields[6]); + c.step = std::stof(fields[7]); + out.push_back(c); + } catch (...) {} + } + return out; +} + +// ── DB lifecycle ────────────────────────────────────────────────────────── + +static bool exec(const char* sql, std::string* err) { + char* msg = nullptr; + int rc = sqlite3_exec(g_db, sql, nullptr, nullptr, &msg); + if (rc != SQLITE_OK) { + if (err) *err = msg ? msg : "sqlite_exec failed"; + if (msg) sqlite3_free(msg); + return false; + } + return true; +} + +bool shaderlab_db_open(const std::string& path) { + if (g_db && path == g_path) return true; + if (g_db) shaderlab_db_close(); + + int rc = sqlite3_open(path.c_str(), &g_db); + if (rc != SQLITE_OK) { + if (g_db) { sqlite3_close(g_db); g_db = nullptr; } + return false; + } + g_path = path; + + const char* schema = + "CREATE TABLE IF NOT EXISTS generators (" + " id TEXT PRIMARY KEY," + " label TEXT NOT NULL," + " description TEXT NOT NULL DEFAULT ''," + " source_glsl TEXT NOT NULL," + " body_glsl TEXT NOT NULL," + " param_count INTEGER NOT NULL," + " param_defaults TEXT NOT NULL," + " param_names TEXT NOT NULL," + " controls TEXT NOT NULL," + " tags TEXT NOT NULL DEFAULT ''," + " created_at TEXT NOT NULL," + " updated_at TEXT NOT NULL" + ");"; + return exec(schema, nullptr); +} + +void shaderlab_db_close() { + if (g_db) sqlite3_close(g_db); + g_db = nullptr; + g_path.clear(); +} + +// ── CRUD ────────────────────────────────────────────────────────────────── + +bool shaderlab_db_save_generator(GeneratorRecord& gen, std::string* err) { + if (!g_db) { if (err) *err = "db not open"; return false; } + + const std::string ts = now_iso(); + if (gen.created_at.empty()) gen.created_at = ts; + gen.updated_at = ts; + + const char* sql = + "INSERT INTO generators " + "(id,label,description,source_glsl,body_glsl,param_count,param_defaults,param_names,controls,tags,created_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(id) DO UPDATE SET " + " label=excluded.label, description=excluded.description, " + " source_glsl=excluded.source_glsl, body_glsl=excluded.body_glsl, " + " param_count=excluded.param_count, param_defaults=excluded.param_defaults, " + " param_names=excluded.param_names, controls=excluded.controls, " + " tags=excluded.tags, updated_at=excluded.updated_at;"; + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, nullptr) != SQLITE_OK) { + if (err) *err = sqlite3_errmsg(g_db); + return false; + } + + const std::string defaults_csv = floats_to_csv(gen.param_defaults); + const std::string names_lf = strings_to_lf(gen.param_names); + const std::string controls_str = controls_to_string(gen.controls); + + sqlite3_bind_text(stmt, 1, gen.id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, gen.label.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, gen.description.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, gen.source_glsl.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, gen.body_glsl.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int (stmt, 6, gen.param_count); + sqlite3_bind_text(stmt, 7, defaults_csv.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 8, names_lf.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 9, controls_str.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 10, gen.tags.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 11, gen.created_at.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 12, gen.updated_at.c_str(), -1, SQLITE_TRANSIENT); + + int rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + if (err) *err = sqlite3_errmsg(g_db); + return false; + } + return true; +} + +static GeneratorRecord row_to_record(sqlite3_stmt* stmt) { + auto col = [&](int i) -> std::string { + const unsigned char* s = sqlite3_column_text(stmt, i); + return s ? reinterpret_cast(s) : ""; + }; + GeneratorRecord r; + r.id = col(0); + r.label = col(1); + r.description = col(2); + r.source_glsl = col(3); + r.body_glsl = col(4); + r.param_count = sqlite3_column_int(stmt, 5); + r.param_defaults = floats_from_csv(col(6)); + r.param_names = strings_from_lf(col(7)); + r.controls = controls_from_string(col(8)); + r.tags = col(9); + r.created_at = col(10); + r.updated_at = col(11); + return r; +} + +std::vector shaderlab_db_list_generators() { + std::vector out; + if (!g_db) return out; + const char* sql = + "SELECT id,label,description,source_glsl,body_glsl,param_count," + " param_defaults,param_names,controls,tags,created_at,updated_at " + "FROM generators ORDER BY label;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, nullptr) != SQLITE_OK) return out; + while (sqlite3_step(stmt) == SQLITE_ROW) out.push_back(row_to_record(stmt)); + sqlite3_finalize(stmt); + return out; +} + +bool shaderlab_db_get_generator(const std::string& id, GeneratorRecord& out) { + if (!g_db) return false; + const char* sql = + "SELECT id,label,description,source_glsl,body_glsl,param_count," + " param_defaults,param_names,controls,tags,created_at,updated_at " + "FROM generators WHERE id = ?;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, nullptr) != SQLITE_OK) return false; + sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); + bool found = false; + if (sqlite3_step(stmt) == SQLITE_ROW) { + out = row_to_record(stmt); + found = true; + } + sqlite3_finalize(stmt); + return found; +} + +bool shaderlab_db_delete_generator(const std::string& id) { + if (!g_db) return false; + const char* sql = "DELETE FROM generators WHERE id = ?;"; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, nullptr) != SQLITE_OK) return false; + sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(stmt); + int changes = sqlite3_changes(g_db); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE && changes > 0; +} + +sqlite3* shaderlab_db_handle() { + return g_db; +} + +} // namespace fn::gfx + +#ifdef SHADERLAB_DB_TEST +#include +#include + +int main() { + using namespace fn::gfx; + + assert(shaderlab_db_open(":memory:")); + + // 1. List on empty db + { + auto v = shaderlab_db_list_generators(); + assert(v.empty()); + } + + // 2. Save + get + { + GeneratorRecord g; + g.id = "watercolor"; + g.label = "watercolor"; + g.description = "soft pastel blob"; + g.source_glsl = "uniform float u_speed; void main() { fragColor = vec4(1.0); }"; + g.body_glsl = " return vec4(1.0);"; + g.param_count = 1; + g.param_defaults = {0.7f}; + g.param_names = {"speed"}; + g.controls = { + { DagControl::Kind::Slider, "velocidad", {0, -1, -1}, 0.0f, 3.0f, 0.01f }, + }; + g.tags = "shaders_lab,user"; + + std::string err; + assert(shaderlab_db_save_generator(g, &err)); + assert(!g.created_at.empty()); + assert(!g.updated_at.empty()); + + GeneratorRecord r; + assert(shaderlab_db_get_generator("watercolor", r)); + assert(r.label == "watercolor"); + assert(r.param_count == 1); + assert(r.param_defaults.size() == 1 && r.param_defaults[0] == 0.7f); + assert(r.param_names.size() == 1 && r.param_names[0] == "speed"); + assert(r.controls.size() == 1); + assert(r.controls[0].kind == DagControl::Kind::Slider); + assert(r.controls[0].label == "velocidad"); + assert(r.controls[0].param_idx[0] == 0); + assert(r.controls[0].max == 3.0f); + } + + // 3. List returns the saved record + { + auto v = shaderlab_db_list_generators(); + assert(v.size() == 1); + assert(v[0].id == "watercolor"); + } + + // 4. Save second generator with multiple controls + { + GeneratorRecord g; + g.id = "chrome"; + g.label = "chrome"; + g.source_glsl = "// stub"; + g.body_glsl = " return vec4(0.0);"; + g.param_count = 4; + g.param_defaults = {1.0f, 2.0f, 0.5f, 0.5f}; + g.param_names = {"a", "b", "c", "d"}; + g.controls = { + { DagControl::Kind::XY, "centro", {0, 1, -1}, -1.0f, 1.0f, 0.01f }, + { DagControl::Kind::Color, "tinte", {1, 2, 3}, 0.0f, 1.0f, 0.0f }, + }; + assert(shaderlab_db_save_generator(g)); + } + + // 5. List ordered by label: chrome then watercolor + { + auto v = shaderlab_db_list_generators(); + assert(v.size() == 2); + assert(v[0].id == "chrome"); + assert(v[1].id == "watercolor"); + assert(v[0].controls.size() == 2); + assert(v[0].controls[1].kind == DagControl::Kind::Color); + assert(v[0].controls[1].param_idx[2] == 3); + } + + // 6. Update preserves created_at, bumps updated_at + { + GeneratorRecord g; + assert(shaderlab_db_get_generator("watercolor", g)); + std::string created = g.created_at; + g.label = "watercolor v2"; + // Force a different timestamp by setting created_at; save will set updated_at = now + assert(shaderlab_db_save_generator(g)); + GeneratorRecord r; + assert(shaderlab_db_get_generator("watercolor", r)); + assert(r.label == "watercolor v2"); + assert(r.created_at == created); + } + + // 7. Delete + { + assert(shaderlab_db_delete_generator("watercolor")); + GeneratorRecord r; + assert(!shaderlab_db_get_generator("watercolor", r)); + auto v = shaderlab_db_list_generators(); + assert(v.size() == 1); + assert(!shaderlab_db_delete_generator("nonexistent")); + } + + shaderlab_db_close(); + std::printf("shaderlab_db: 7/7 asserts passed\n"); + return 0; +} +#endif diff --git a/cpp/functions/gfx/shaderlab_db.h b/cpp/functions/gfx/shaderlab_db.h new file mode 100644 index 00000000..826206a8 --- /dev/null +++ b/cpp/functions/gfx/shaderlab_db.h @@ -0,0 +1,50 @@ +#pragma once +#include "gfx/dag_types.h" +#include +#include + +struct sqlite3; // fwd decl + +namespace fn::gfx { + +// Persistent record for a user-saved generator (Code → DAG Gen). +struct GeneratorRecord { + std::string id; // snake_case unique + std::string label; // visible name in palette + std::string description; // free text + std::string source_glsl; // original Code (with fragColor, uniforms) + std::string body_glsl; // translated DAG Gen body (with return) + int param_count = 0; // number of floats in u_params block + std::vector param_defaults; + std::vector param_names; + std::vector controls; + std::string tags; // CSV + std::string created_at; // ISO-8601 (set by save if empty) + std::string updated_at; // ISO-8601 (set by save) +}; + +// Open the database at `path` (created if missing). ":memory:" is supported for tests. +// Returns true on success. Idempotent: calling open() again with the same path is a no-op. +bool shaderlab_db_open(const std::string& path); + +// Close the active connection (no-op if not open). +void shaderlab_db_close(); + +// Insert or replace a generator. Sets gen.created_at if empty and updated_at unconditionally +// (caller's struct is updated in place too). Returns false on SQL error. +bool shaderlab_db_save_generator(GeneratorRecord& gen, std::string* err = nullptr); + +// Load all generators ordered by label. +std::vector shaderlab_db_list_generators(); + +// Load a single generator by id. Returns false if not found. +bool shaderlab_db_get_generator(const std::string& id, GeneratorRecord& out); + +// Remove a generator by id. Returns true if a row was deleted. +bool shaderlab_db_delete_generator(const std::string& id); + +// Returns the underlying sqlite3* handle (or nullptr if not open). +// For composing additional tables (e.g. ui_layouts) on the same connection. +sqlite3* shaderlab_db_handle(); + +} // namespace fn::gfx diff --git a/cpp/functions/gfx/shaderlab_db.md b/cpp/functions/gfx/shaderlab_db.md new file mode 100644 index 00000000..d8ba3ab9 --- /dev/null +++ b/cpp/functions/gfx/shaderlab_db.md @@ -0,0 +1,64 @@ +--- +name: shaderlab_db +kind: function +lang: cpp +domain: gfx +version: "1.0.0" +purity: impure +signature: "bool shaderlab_db_save_generator(GeneratorRecord& gen, std::string* err); std::vector shaderlab_db_list_generators(); bool shaderlab_db_get_generator(const std::string& id, GeneratorRecord& out); bool shaderlab_db_delete_generator(const std::string& id);" +description: "CRUD persistente para generators custom de shaders_lab via sqlite3. Guarda el GLSL original, el body traducido para el DAG, los DagControl y los param_defaults en una BD local (shaders_lab.db). Soporta open(:memory:) para tests." +tags: [sqlite, shaders, glsl, dag, gfx, persistence, crud, shaders_lab] +uses_functions: [] +uses_types: + - dag_types_cpp_gfx +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [sqlite3, string, vector, chrono, ctime, iomanip, sstream] +tested: true +tests: + - "open in-memory + list empty" + - "save + get roundtrip preserva controls/params/defaults" + - "list ordenado por label" + - "update preserva created_at y bumps updated_at" + - "delete devuelve true solo si existia" +test_file_path: "cpp/functions/gfx/shaderlab_db.cpp" +file_path: "cpp/functions/gfx/shaderlab_db.cpp" +params: + - name: gen + desc: "Registro del generator. Debe llevar id (snake_case), label, source_glsl original, body_glsl traducido, param_count y los param_defaults/controls coherentes." + - name: id + desc: "ID snake_case del generator (clave primaria de la tabla)." + - name: out + desc: "Registro de salida en get; queda relleno solo si la funcion devuelve true." + - name: err + desc: "Mensaje de error opcional si save falla (constraint, schema, IO)." +output: "Persistencia en la tabla generators de shaders_lab.db. Las listas vienen ordenadas por label." +--- + +## Schema + +```sql +CREATE TABLE generators ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + source_glsl TEXT NOT NULL, + body_glsl TEXT NOT NULL, + param_count INTEGER NOT NULL, + param_defaults TEXT NOT NULL, -- CSV de floats + param_names TEXT NOT NULL, -- LF-separated strings + controls TEXT NOT NULL, -- LF-separated, fields '|': kind|label|p0|p1|p2|min|max|step + tags TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +``` + +## Notas + +- Formato de serializacion custom (no JSON): CSV para floats, LF para strings, `|` para fields de control. Sin escape de separadores; los labels no deben contener `\n` ni `|`. +- `shaderlab_db_open` es idempotente: re-abrir con el mismo path es no-op. +- `:memory:` como path crea una BD temporal en memoria (usado en tests). +- `save_generator` hace upsert (`ON CONFLICT DO UPDATE`). Setea `created_at` solo si esta vacio. +- Tests inline activables con `-DSHADERLAB_DB_TEST` y linkando sqlite3 (amalgamation o `-lsqlite3`).