From 580e4ba1fd7fdd799f1a63494b3db4c5d2913e31 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:05 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(gfx):=20mesh=5Fobj=5Fload=20=E2=80=94?= =?UTF-8?q?=20minimal=20Wavefront=20.obj=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh_obj_parse (pure) + mesh_obj_load (impure file helper). Soporta v / vn / f (tris y quads). Genera normales per-face si faltan (flat shading). Quads se parten en 2 tris; n-gons (>4) se descartan silenciosamente. Indices 1-based positivos y negativos. issue 0029 --- cpp/functions/gfx/mesh_obj_load.cpp | 241 ++++++++++++++++++++++++++++ cpp/functions/gfx/mesh_obj_load.h | 29 ++++ cpp/functions/gfx/mesh_obj_load.md | 75 +++++++++ 3 files changed, 345 insertions(+) create mode 100644 cpp/functions/gfx/mesh_obj_load.cpp create mode 100644 cpp/functions/gfx/mesh_obj_load.h create mode 100644 cpp/functions/gfx/mesh_obj_load.md diff --git a/cpp/functions/gfx/mesh_obj_load.cpp b/cpp/functions/gfx/mesh_obj_load.cpp new file mode 100644 index 00000000..94913a59 --- /dev/null +++ b/cpp/functions/gfx/mesh_obj_load.cpp @@ -0,0 +1,241 @@ +#include "gfx/mesh_obj_load.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fn::gfx { + +namespace { + +// Parse one face vertex token "v", "v/t", "v//n", or "v/t/n". +// Returns 0-based positive index for v_idx and n_idx (or -1 if not present). +// .obj indices are 1-based; negative indices are relative-to-end. +struct FaceVert { int v = -1; int n = -1; }; + +FaceVert parse_face_vert(const char* tok, int n_pos, int n_nrm) { + FaceVert r; + int v = 0, t = 0, n = 0; + int slashes = 0; + const char* p = tok; + // crude split by '/' + char buf[3][32]; + int bi = 0; + int bj = 0; + buf[0][0] = buf[1][0] = buf[2][0] = '\0'; + while (*p && *p != '\0' && *p != ' ' && *p != '\t' && *p != '\r' && *p != '\n') { + if (*p == '/') { buf[bi][bj] = '\0'; bi++; bj = 0; if (bi > 2) break; slashes++; } + else if (bj < 31) { buf[bi][bj++] = *p; } + p++; + } + if (bi <= 2) buf[bi][bj] = '\0'; + + if (buf[0][0]) v = std::atoi(buf[0]); + if (slashes >= 1 && buf[1][0]) t = std::atoi(buf[1]); + if (slashes == 2 && buf[2][0]) n = std::atoi(buf[2]); + (void)t; + + // resolve 1-based / negative + if (v > 0) r.v = v - 1; + else if (v < 0) r.v = n_pos + v; + if (n > 0) r.n = n - 1; + else if (n < 0) r.n = n_nrm + n; + return r; +} + +void cross3(const float a[3], const float b[3], float out[3]) { + out[0] = a[1]*b[2] - a[2]*b[1]; + out[1] = a[2]*b[0] - a[0]*b[2]; + out[2] = a[0]*b[1] - a[1]*b[0]; +} +void sub3(const float a[3], const float b[3], float out[3]) { + out[0] = a[0]-b[0]; out[1] = a[1]-b[1]; out[2] = a[2]-b[2]; +} +void normalize3(float v[3]) { + float l = std::sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); + if (l > 0) { v[0] /= l; v[1] /= l; v[2] /= l; } +} + +} // namespace + +Mesh mesh_obj_parse(const char* obj_text, size_t len) { + Mesh m; + if (!obj_text || len == 0) return m; + + // Raw arrays from the file. + std::vector raw_pos; // stride 3 + std::vector raw_nrm; // stride 3 + + // Per-output-vertex: dedup by (v_idx, n_idx) tuple. Map "v_n" -> out index. + // For tiny meshes a linear scan of pairs suffices, but we use a vector + + // simple hash map via std::string key (KISS, ~MB-scale meshes still ok). + struct Key { int v; int n; }; + std::vector out_keys; + + auto find_or_add = [&](int v_idx, int n_idx) -> uint32_t { + // linear scan — fine for inspection meshes (<<100k unique verts). + for (uint32_t i = 0; i < out_keys.size(); ++i) { + if (out_keys[i].v == v_idx && out_keys[i].n == n_idx) return i; + } + out_keys.push_back({v_idx, n_idx}); + return (uint32_t)(out_keys.size() - 1); + }; + + // Iterate lines. + const char* p = obj_text; + const char* end = obj_text + len; + + // Stash face vertices for second pass (if normals are missing globally + // we generate per face). + struct FaceTri { FaceVert v[3]; }; + std::vector faces; + + auto skip_ws = [](const char*& q, const char* qend) { + while (q < qend && (*q == ' ' || *q == '\t')) q++; + }; + + while (p < end) { + // skip leading whitespace + skip_ws(p, end); + if (p >= end) break; + if (*p == '#') { + while (p < end && *p != '\n') p++; + if (p < end) p++; + continue; + } + if (*p == '\n' || *p == '\r') { p++; continue; } + + // tag + const char* tag_start = p; + while (p < end && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; + size_t tag_len = (size_t)(p - tag_start); + + auto eat_line = [&](std::string& out) { + skip_ws(p, end); + const char* line_start = p; + while (p < end && *p != '\n' && *p != '\r') p++; + out.assign(line_start, p); + while (p < end && (*p == '\n' || *p == '\r')) p++; + }; + + if (tag_len == 1 && tag_start[0] == 'v') { + std::string body; eat_line(body); + float x = 0, y = 0, z = 0; + std::sscanf(body.c_str(), "%f %f %f", &x, &y, &z); + raw_pos.push_back(x); raw_pos.push_back(y); raw_pos.push_back(z); + } else if (tag_len == 2 && tag_start[0] == 'v' && tag_start[1] == 'n') { + std::string body; eat_line(body); + float x = 0, y = 0, z = 0; + std::sscanf(body.c_str(), "%f %f %f", &x, &y, &z); + raw_nrm.push_back(x); raw_nrm.push_back(y); raw_nrm.push_back(z); + } else if (tag_len == 1 && tag_start[0] == 'f') { + std::string body; eat_line(body); + // Split body into space-delimited tokens. + std::vector fv; + const char* bp = body.c_str(); + const char* be = bp + body.size(); + int npos = (int)(raw_pos.size() / 3); + int nnrm = (int)(raw_nrm.size() / 3); + while (bp < be) { + while (bp < be && (*bp == ' ' || *bp == '\t')) bp++; + if (bp >= be) break; + fv.push_back(parse_face_vert(bp, npos, nnrm)); + while (bp < be && *bp != ' ' && *bp != '\t') bp++; + } + if (fv.size() == 3) { + faces.push_back({fv[0], fv[1], fv[2]}); + } else if (fv.size() == 4) { + faces.push_back({fv[0], fv[1], fv[2]}); + faces.push_back({fv[0], fv[2], fv[3]}); + } + // n-gons silently dropped; documented in .md + } else { + // unknown tag — skip to EOL + while (p < end && *p != '\n') p++; + if (p < end) p++; + } + } + + if (raw_pos.empty() || faces.empty()) return Mesh{}; + + // Build output vertices/normals/indices. + // If any face vertex lacks a normal, we'll generate face normals. + bool need_face_normals = raw_nrm.empty(); + if (!need_face_normals) { + for (auto& f : faces) { + for (int k = 0; k < 3; ++k) { + if (f.v[k].n < 0) { need_face_normals = true; break; } + } + if (need_face_normals) break; + } + } + + // First pass: build deduped vertex list with explicit normals (when present). + // If we need face normals, we'll emit unique vertices per face (no dedup + // across faces for normals; positions can still be shared but we just + // duplicate to keep code simple — this is flat shading by design). + if (need_face_normals) { + m.positions.reserve(faces.size() * 3 * 3); + m.normals.reserve(faces.size() * 3 * 3); + m.indices.reserve(faces.size() * 3); + uint32_t next = 0; + for (auto& f : faces) { + const float* p0 = &raw_pos[f.v[0].v * 3]; + const float* p1 = &raw_pos[f.v[1].v * 3]; + const float* p2 = &raw_pos[f.v[2].v * 3]; + float e1[3], e2[3], n[3]; + sub3(p1, p0, e1); + sub3(p2, p0, e2); + cross3(e1, e2, n); + normalize3(n); + for (int k = 0; k < 3; ++k) { + const float* pp = &raw_pos[f.v[k].v * 3]; + m.positions.push_back(pp[0]); + m.positions.push_back(pp[1]); + m.positions.push_back(pp[2]); + m.normals.push_back(n[0]); + m.normals.push_back(n[1]); + m.normals.push_back(n[2]); + m.indices.push_back(next++); + } + } + } else { + m.indices.reserve(faces.size() * 3); + for (auto& f : faces) { + for (int k = 0; k < 3; ++k) { + uint32_t out = find_or_add(f.v[k].v, f.v[k].n); + m.indices.push_back(out); + } + } + m.positions.resize(out_keys.size() * 3); + m.normals.resize(out_keys.size() * 3); + for (size_t i = 0; i < out_keys.size(); ++i) { + int vi = out_keys[i].v; + int ni = out_keys[i].n; + m.positions[i*3 + 0] = raw_pos[vi*3 + 0]; + m.positions[i*3 + 1] = raw_pos[vi*3 + 1]; + m.positions[i*3 + 2] = raw_pos[vi*3 + 2]; + m.normals[i*3 + 0] = raw_nrm[ni*3 + 0]; + m.normals[i*3 + 1] = raw_nrm[ni*3 + 1]; + m.normals[i*3 + 2] = raw_nrm[ni*3 + 2]; + } + } + + return m; +} + +Mesh mesh_obj_load(const char* path) { + if (!path || !*path) return Mesh{}; + std::ifstream f(path, std::ios::binary); + if (!f) return Mesh{}; + std::ostringstream ss; + ss << f.rdbuf(); + std::string s = ss.str(); + return mesh_obj_parse(s.data(), s.size()); +} + +} // namespace fn::gfx diff --git a/cpp/functions/gfx/mesh_obj_load.h b/cpp/functions/gfx/mesh_obj_load.h new file mode 100644 index 00000000..2292a6e4 --- /dev/null +++ b/cpp/functions/gfx/mesh_obj_load.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace fn::gfx { + +// CPU-side mesh: positions interleaved with normals stride 3 floats each, +// indexed by `indices`. +struct Mesh { + std::vector positions; // x,y,z stride=3 (count = num_vertices*3) + std::vector normals; // x,y,z stride=3 (same length as positions) + std::vector indices; // tri-list (count multiple of 3) +}; + +// Parse Wavefront .obj from a text buffer. Pure: no I/O. +// Supports: v, vn, f (triangles and quads). Ignores: vt, mtllib, usemtl, o, g, s. +// If the file has no vn, normals are generated per-face (flat shading). +// Quads in `f` are split into 2 triangles (fan). N-gons (>4 verts) are not supported. +// +// Returns an empty Mesh ({}, {}, {}) on parse failure (no vertices found). +Mesh mesh_obj_parse(const char* obj_text, size_t len); + +// Read file from disk and parse. IMPURE (file I/O). +// Returns empty Mesh on read or parse failure. +Mesh mesh_obj_load(const char* path); + +} // namespace fn::gfx diff --git a/cpp/functions/gfx/mesh_obj_load.md b/cpp/functions/gfx/mesh_obj_load.md new file mode 100644 index 00000000..a2172a0d --- /dev/null +++ b/cpp/functions/gfx/mesh_obj_load.md @@ -0,0 +1,75 @@ +--- +name: mesh_obj_load +kind: function +lang: cpp +domain: gfx +version: "1.0.0" +purity: pure +signature: "Mesh mesh_obj_parse(const char* obj_text, size_t len); Mesh mesh_obj_load(const char* path)" +description: "Parser minimal de Wavefront .obj — soporta v, vn, f (tris y quads). Genera normales por face si faltan. mesh_obj_parse es puro; mesh_obj_load es helper impuro que lee fichero y delega." +tags: [obj, mesh, parser, wavefront, loader, geometry, 3d] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [fstream, sstream, cmath] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/gfx/mesh_obj_load.cpp" +framework: opengl +params: + - name: obj_text + desc: "Buffer de texto .obj. Lineas reconocidas: v, vn, f. Comentarios con #. UTF-8 plano." + - name: len + desc: "Longitud del buffer en bytes" + - name: path + desc: "Ruta absoluta o relativa del .obj a leer (mesh_obj_load impuro)" +output: "Mesh con positions/normals (stride 3, mismo length) y indices (tri-list, multiplo de 3). Si no hay vn, normales por face (flat shading) y vertices duplicados por face. Mesh vacio si parse falla." +--- + +# mesh_obj_load + +Parser pequeno de Wavefront `.obj` para inspeccion de geometria. KISS: cubre el subconjunto que la mayoria de exporters genera (Blender default, MeshLab, etc). + +## Soporta + +- `v x y z` — vertice (xyz; w opcional ignorado) +- `vn x y z` — normal +- `f a b c` y `f a b c d` — tris y quads (los quads se dividen en 2 tris) +- Indices `v`, `v/t`, `v//n`, `v/t/n` (vt se ignora; t no afecta) +- Indices 1-based positivos y negativos (relative-to-end, conforme spec) +- Lineas en blanco y comentarios con `#` + +## NO soporta (en este issue) + +- N-gons con mas de 4 vertices → silenciosamente descartados +- `vt` (coordenadas de textura) — el indice se parsea pero el dato se ignora +- `mtllib`, `usemtl`, `o`, `g`, `s` — se saltan +- Lineas de polilineas (`l`) +- Curvas, superficies, `vp` + +## Generacion de normales + +Si el `.obj` no tiene `vn` (o si alguna face no las referencia), se generan normales por face (flat shading). Esto duplica vertices entre faces que comparten posicion pero no normal — es el comportamiento esperado para inspeccion. + +Cuando todas las faces tienen normales explicitas, se hace dedup `(v_idx, n_idx)` con un linear-scan simple. Para meshes inspeccion (<<100k verts unicos) es perf-OK; meshes mas grandes se beneficiarian de un hashmap. + +## Errores + +Si el archivo no tiene vertices o no tiene faces, devuelve `Mesh{}` (positions/indices vacios). El caller puede chequear con `mesh.positions.empty()`. + +## Test inline (cubo) + +```cpp +const char* obj = + "v -1 -1 -1\nv 1 -1 -1\nv 1 1 -1\nv -1 1 -1\n" + "v -1 -1 1\nv 1 -1 1\nv 1 1 1\nv -1 1 1\n" + "f 1 2 3 4\nf 5 6 7 8\nf 1 2 6 5\n" + "f 4 3 7 8\nf 1 4 8 5\nf 2 3 7 6\n"; +Mesh m = mesh_obj_parse(obj, std::strlen(obj)); +// 6 quads -> 12 tris -> 36 indices. +// Sin vn -> face normals -> 36 verts emitidos (duplicados por face). +assert(m.indices.size() == 36); +``` From 44e189c5cc57e97457beaea49a915353762c3b79 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:10 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(gfx):=20mesh=5Fgpu=20=E2=80=94=20VAO/V?= =?UTF-8?q?BO/EBO=20upload=20para=20Mesh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh_gpu_upload sube positions+normals interleaved (stride 6 floats, location 0=a_pos, location 1=a_normal) + EBO uint32. mesh_gpu_destroy libera todo. GL_STATIC_DRAW (mesh inmutable post-upload). issue 0029 --- cpp/functions/gfx/mesh_gpu.cpp | 69 ++++++++++++++++++++++++++++++++++ cpp/functions/gfx/mesh_gpu.h | 25 ++++++++++++ cpp/functions/gfx/mesh_gpu.md | 68 +++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 cpp/functions/gfx/mesh_gpu.cpp create mode 100644 cpp/functions/gfx/mesh_gpu.h create mode 100644 cpp/functions/gfx/mesh_gpu.md diff --git a/cpp/functions/gfx/mesh_gpu.cpp b/cpp/functions/gfx/mesh_gpu.cpp new file mode 100644 index 00000000..6b8ea084 --- /dev/null +++ b/cpp/functions/gfx/mesh_gpu.cpp @@ -0,0 +1,69 @@ +#include "gfx/mesh_gpu.h" + +#include + +namespace fn::gfx { + +MeshGpu mesh_gpu_upload(const Mesh& mesh) { + MeshGpu g{}; + if (mesh.positions.empty() || mesh.indices.empty()) return g; + if (mesh.normals.size() != mesh.positions.size()) return g; + + const size_t nverts = mesh.positions.size() / 3; + + // Interleave pos.xyz + normal.xyz, stride 6 floats. + std::vector interleaved; + interleaved.resize(nverts * 6); + for (size_t i = 0; i < nverts; ++i) { + interleaved[i*6 + 0] = mesh.positions[i*3 + 0]; + interleaved[i*6 + 1] = mesh.positions[i*3 + 1]; + interleaved[i*6 + 2] = mesh.positions[i*3 + 2]; + interleaved[i*6 + 3] = mesh.normals[i*3 + 0]; + interleaved[i*6 + 4] = mesh.normals[i*3 + 1]; + interleaved[i*6 + 5] = mesh.normals[i*3 + 2]; + } + + glGenVertexArrays(1, &g.vao); + glGenBuffers(1, &g.vbo); + glGenBuffers(1, &g.ebo); + + glBindVertexArray(g.vao); + + glBindBuffer(GL_ARRAY_BUFFER, g.vbo); + glBufferData(GL_ARRAY_BUFFER, + (GLsizeiptr)(interleaved.size() * sizeof(float)), + interleaved.data(), + GL_STATIC_DRAW); + + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, g.ebo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, + (GLsizeiptr)(mesh.indices.size() * sizeof(uint32_t)), + mesh.indices.data(), + GL_STATIC_DRAW); + + // location 0 = a_pos (vec3) + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, + (GLsizei)(6 * sizeof(float)), + (const void*)0); + // location 1 = a_normal (vec3) + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, + (GLsizei)(6 * sizeof(float)), + (const void*)(3 * sizeof(float))); + + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindVertexArray(0); + + g.index_count = (int)mesh.indices.size(); + return g; +} + +void mesh_gpu_destroy(MeshGpu& g) { + if (g.ebo) { glDeleteBuffers(1, &g.ebo); g.ebo = 0; } + if (g.vbo) { glDeleteBuffers(1, &g.vbo); g.vbo = 0; } + if (g.vao) { glDeleteVertexArrays(1, &g.vao); g.vao = 0; } + g.index_count = 0; +} + +} // namespace fn::gfx diff --git a/cpp/functions/gfx/mesh_gpu.h b/cpp/functions/gfx/mesh_gpu.h new file mode 100644 index 00000000..e1f37576 --- /dev/null +++ b/cpp/functions/gfx/mesh_gpu.h @@ -0,0 +1,25 @@ +#pragma once + +#include "gfx/gl_loader.h" +#include "gfx/mesh_obj_load.h" + +namespace fn::gfx { + +// VAO + VBO interleaved (pos.xyz, nrm.xyz) + EBO. ok() despues de upload. +struct MeshGpu { + GLuint vao = 0; + GLuint vbo = 0; + GLuint ebo = 0; + int index_count = 0; + bool ok() const { return vao != 0 && index_count > 0; } +}; + +// Sube un Mesh CPU al GPU. Requiere contexto GL activo. +// Si Mesh esta vacio o no es valido, devuelve MeshGpu{} (ok() == false). +// Layout: location 0 = vec3 a_pos, location 1 = vec3 a_normal. +MeshGpu mesh_gpu_upload(const Mesh& mesh); + +// Libera los recursos GL. Seguro con mesh_gpu vacio. +void mesh_gpu_destroy(MeshGpu& mesh_gpu); + +} // namespace fn::gfx diff --git a/cpp/functions/gfx/mesh_gpu.md b/cpp/functions/gfx/mesh_gpu.md new file mode 100644 index 00000000..c311ce50 --- /dev/null +++ b/cpp/functions/gfx/mesh_gpu.md @@ -0,0 +1,68 @@ +--- +name: mesh_gpu +kind: function +lang: cpp +domain: gfx +version: "1.0.0" +purity: impure +signature: "MeshGpu mesh_gpu_upload(const Mesh&); void mesh_gpu_destroy(MeshGpu&)" +description: "Sube un Mesh CPU a OpenGL como VAO + VBO interleaved (pos.xyz, normal.xyz) + EBO uint32. Layout: location 0 = a_pos vec3, location 1 = a_normal vec3, stride 6 floats." +tags: [opengl, mesh, vao, vbo, ebo, gpu, gfx] +uses_functions: [mesh_obj_load_cpp_gfx] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [GL/gl.h, GL/glext.h] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/gfx/mesh_gpu.cpp" +framework: opengl +params: + - name: mesh + desc: "Mesh CPU con positions/normals (mismo length, stride 3) e indices uint32. Si esta vacio o invalido, upload devuelve MeshGpu{} (ok()==false)." + - name: mesh_gpu + desc: "MeshGpu (vao/vbo/ebo, index_count). destroy libera todo y pone IDs a 0." +output: "mesh_gpu_upload: MeshGpu listo para draw con glDrawElements(GL_TRIANGLES, index_count, GL_UNSIGNED_INT, 0). Si !ok(), no hubo upload." +--- + +# mesh_gpu + +CRUD GPU minimal para `Mesh`. Asume contexto OpenGL 3.3+ activo. + +## Layout de attribs + +```glsl +#version 330 core +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; +``` + +Stride = `6 * sizeof(float)`, sin padding. + +## Uso tipico + +```cpp +auto cpu = fn::gfx::mesh_obj_load("model.obj"); +auto gpu = fn::gfx::mesh_gpu_upload(cpu); +if (!gpu.ok()) { /* falla */ return; } + +// Draw: +glUseProgram(prog); +glBindVertexArray(gpu.vao); +glDrawElements(GL_TRIANGLES, gpu.index_count, GL_UNSIGNED_INT, 0); +glBindVertexArray(0); + +// Cleanup: +fn::gfx::mesh_gpu_destroy(gpu); +``` + +## Validacion de input + +`mesh_gpu_upload` exige que `mesh.normals.size() == mesh.positions.size()`. `mesh_obj_parse` siempre genera normales (per-face si faltan) → invariante natural. + +## Notas + +- Indices son `GL_UNSIGNED_INT` (32-bit) para soportar meshes grandes sin tener que decidir formato dinamicamente. +- `GL_STATIC_DRAW`: el assumption es que la malla no cambia post-upload. Si necesitas streaming, crear otro helper con `GL_DYNAMIC_DRAW`. From 3b662ac4c3d12a089a18359ca5f0536ce0209163 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:15 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(core):=20orbit=5Fcamera=20=E2=80=94=20?= =?UTF-8?q?camara=20orbital=20pura=20(matrices=20view/proj=20+=20drag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State minimal (azimuth, elevation, distance, fov, aspect, near/far). orbit_camera_matrices: lookAt+perspective row-major float[16] (subir a GL con transpose=GL_TRUE). orbit_camera_handle_drag: dx→azimuth, dy→elevation (clamp ±π/2-eps), wheel→distance (clamp >0.1). Sin glm, solo . issue 0029 --- cpp/functions/core/orbit_camera.cpp | 92 +++++++++++++++++++++++++++++ cpp/functions/core/orbit_camera.h | 40 +++++++++++++ cpp/functions/core/orbit_camera.md | 68 +++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 cpp/functions/core/orbit_camera.cpp create mode 100644 cpp/functions/core/orbit_camera.h create mode 100644 cpp/functions/core/orbit_camera.md diff --git a/cpp/functions/core/orbit_camera.cpp b/cpp/functions/core/orbit_camera.cpp new file mode 100644 index 00000000..161e99a6 --- /dev/null +++ b/cpp/functions/core/orbit_camera.cpp @@ -0,0 +1,92 @@ +#include "core/orbit_camera.h" +#include "imgui.h" + +#include + +namespace fn::core { + +namespace { + +constexpr float kPi = 3.14159265358979323846f; + +// row-major identity +void mat4_identity(float m[16]) { + for (int i = 0; i < 16; ++i) m[i] = 0.0f; + m[0] = m[5] = m[10] = m[15] = 1.0f; +} + +// row-major: m[row*4 + col] +void make_lookat(float view[16], + float ex, float ey, float ez, + float cx, float cy, float cz, + float ux, float uy, float uz) { + // forward = normalize(center - eye) + float fx = cx - ex, fy = cy - ey, fz = cz - ez; + float fl = std::sqrt(fx*fx + fy*fy + fz*fz); + if (fl > 0) { fx /= fl; fy /= fl; fz /= fl; } + // side = normalize(cross(forward, up)) + float sx = fy*uz - fz*uy; + float sy = fz*ux - fx*uz; + float sz = fx*uy - fy*ux; + float sl = std::sqrt(sx*sx + sy*sy + sz*sz); + if (sl > 0) { sx /= sl; sy /= sl; sz /= sl; } + // up' = cross(side, forward) + float ux2 = sy*fz - sz*fy; + float uy2 = sz*fx - sx*fz; + float uz2 = sx*fy - sy*fx; + + mat4_identity(view); + view[0] = sx; view[1] = sy; view[2] = sz; view[3] = -(sx*ex + sy*ey + sz*ez); + view[4] = ux2; view[5] = uy2; view[6] = uz2; view[7] = -(ux2*ex + uy2*ey + uz2*ez); + view[8] = -fx; view[9] = -fy; view[10] = -fz; view[11] = (fx*ex + fy*ey + fz*ez); + view[12] = 0; view[13] = 0; view[14] = 0; view[15] = 1; +} + +void make_perspective(float proj[16], float fov_deg, float aspect, float n, float f) { + float fov_rad = fov_deg * kPi / 180.0f; + float t = 1.0f / std::tan(fov_rad * 0.5f); + for (int i = 0; i < 16; ++i) proj[i] = 0.0f; + proj[0] = t / aspect; + proj[5] = t; + proj[10] = (f + n) / (n - f); + proj[11] = (2.0f * f * n) / (n - f); + proj[14] = -1.0f; + proj[15] = 0.0f; +} + +} // namespace + +CameraMatrices orbit_camera_matrices(const OrbitCamera& cam) { + CameraMatrices m{}; + // Convert spherical (azimuth, elevation, distance) -> cartesian eye position. + // azimuth rotates around Y; elevation tilts up/down. + float ce = std::cos(cam.elevation); + float se = std::sin(cam.elevation); + float ca = std::cos(cam.azimuth); + float sa = std::sin(cam.azimuth); + float ex = cam.distance * ce * sa; + float ey = cam.distance * se; + float ez = cam.distance * ce * ca; + + make_lookat(m.view, ex, ey, ez, 0, 0, 0, 0, 1, 0); + make_perspective(m.proj, cam.fov, cam.aspect, cam.near_plane, cam.far_plane); + return m; +} + +void orbit_camera_handle_drag(OrbitCamera& cam, ImVec2 drag_delta, float wheel) { + constexpr float kDragSens = 0.01f; + cam.azimuth += drag_delta.x * kDragSens; + cam.elevation += drag_delta.y * kDragSens; + + // clamp elevation to ±π/2 - eps to avoid gimbal flip + constexpr float kElevMax = kPi * 0.5f - 1e-3f; + if (cam.elevation > kElevMax) cam.elevation = kElevMax; + if (cam.elevation < -kElevMax) cam.elevation = -kElevMax; + + if (wheel != 0.0f) { + cam.distance *= (1.0f - wheel * 0.1f); + if (cam.distance < 0.1f) cam.distance = 0.1f; + } +} + +} // namespace fn::core diff --git a/cpp/functions/core/orbit_camera.h b/cpp/functions/core/orbit_camera.h new file mode 100644 index 00000000..a7bb55a8 --- /dev/null +++ b/cpp/functions/core/orbit_camera.h @@ -0,0 +1,40 @@ +#pragma once + +// orbit_camera — camara orbital pura. Estado minimo (azimuth, elevation, +// distance, fov, aspect, near/far) y dos helpers: matrices view/proj y +// handle_drag para integrar drag/wheel del mouse. +// +// Las matrices se devuelven como float[16] row-major, pero al subirlas a +// OpenGL con glUniformMatrix4fv hay que pasar transpose=GL_TRUE (porque +// GL espera column-major). Ver mesh_viewer.cpp para el patron. + +struct ImVec2; // fwd-decl, definido por imgui.h + +namespace fn::core { + +struct OrbitCamera { + float azimuth = 0.7f; // rad, rotacion en plano XZ + float elevation = 0.4f; // rad, rotacion en plano YZ, clamp ±π/2-eps + float distance = 3.0f; // distancia al origen (target=0,0,0) + float fov = 45.0f; // grados, vertical FOV + float aspect = 1.0f; // width/height del viewport + float near_plane = 0.05f; + float far_plane = 100.0f; +}; + +struct CameraMatrices { + float view[16]; // row-major + float proj[16]; // row-major +}; + +// Calcula view (lookAt eye→origin, up=Y) y proj (perspective). Pure. +CameraMatrices orbit_camera_matrices(const OrbitCamera& cam); + +// Aplica un drag de mouse (dx,dy) y rueda (wheel) sobre la camara. +// dx → azimuth (sensibilidad 0.01 rad/px) +// dy → elevation (clamp a ±π/2 - 1e-3) +// wheel → distance *= (1 - wheel*0.1), clamp >0.1 +// Pure (sin side effects mas alla de mutar la struct in-place). +void orbit_camera_handle_drag(OrbitCamera& cam, ImVec2 drag_delta, float wheel); + +} // namespace fn::core diff --git a/cpp/functions/core/orbit_camera.md b/cpp/functions/core/orbit_camera.md new file mode 100644 index 00000000..c7f59aa0 --- /dev/null +++ b/cpp/functions/core/orbit_camera.md @@ -0,0 +1,68 @@ +--- +name: orbit_camera +kind: function +lang: cpp +domain: core +version: "1.0.0" +purity: pure +signature: "CameraMatrices orbit_camera_matrices(const OrbitCamera&); void orbit_camera_handle_drag(OrbitCamera&, ImVec2 drag_delta, float wheel)" +description: "Camara orbital pura — estado minimal (azimuth, elevation, distance, fov, aspect, near/far). Helpers: matrices view/proj (row-major float[16]) y handle_drag para integrar drag/wheel." +tags: [camera, orbit, math, matrix, lookat, perspective, 3d] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [imgui] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/core/orbit_camera.cpp" +framework: imgui +params: + - name: cam + desc: "OrbitCamera in/out: azimuth (rad), elevation (rad clamped ±π/2-eps), distance, fov (deg), aspect, near/far" + - name: drag_delta + desc: "ImVec2 desplazamiento del mouse desde la ultima frame (px). dx→azimuth, dy→elevation con sensibilidad 0.01 rad/px" + - name: wheel + desc: "Float scroll wheel (Imgui::GetIO().MouseWheel). distance *= (1 - wheel*0.1), clamp >0.1" +output: "orbit_camera_matrices: CameraMatrices con view (lookAt eye→origin, up=Y) y proj (perspective). Ambas row-major; al pasar a glUniformMatrix4fv usar transpose=GL_TRUE. orbit_camera_handle_drag: muta cam in-place, sin allocacion." +--- + +# orbit_camera + +Camara orbital trivial para inspeccionar geometria 3D. Mantiene un objetivo fijo en el origen y rota la posicion del ojo en una esfera de radio `distance`. + +## Convenciones + +- **Azimuth = 0, elevation = 0** → ojo en `+Z`, mirando hacia `-Z`. +- **Up vector** = `+Y`. Right-handed. +- **Matrices row-major**: al subir a OpenGL, usar `glUniformMatrix4fv(loc, 1, GL_TRUE, &m.view[0])`. +- Sin glm: ~50 LOC, sin dependencias mas alla de ``. + +## Uso tipico + +```cpp +fn::core::OrbitCamera cam; +cam.aspect = (float)w / (float)h; + +// En el render loop: +auto m = fn::core::orbit_camera_matrices(cam); +glUniformMatrix4fv(loc_view, 1, GL_TRUE, m.view); +glUniformMatrix4fv(loc_proj, 1, GL_TRUE, m.proj); + +// Si el panel esta activo: +if (ImGui::IsItemActive()) { + fn::core::orbit_camera_handle_drag( + cam, + ImGui::GetIO().MouseDelta, + ImGui::GetIO().MouseWheel + ); +} +``` + +## Notas + +- Elevation se clampea a `±π/2 - 1e-3` para evitar flip de la up vector cuando se mira desde polos. +- Distance minima = 0.1 (impide cruzar el origen con el wheel). +- Si necesitas un target distinto al origen, suma `target` a `eye` y pasa `target` a la lookAt — el helper actual mira fijo a `(0,0,0)` por simplicidad. From 281502ac9298ceb0fc4b9b9b739987789d0cb2f8 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:22 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(viz):=20mesh=5Fviewer=20=E2=80=94=20co?= =?UTF-8?q?mponente=203D=20con=20FBO=20+=20Lambert=20headlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compila/cachea por id un programa GLSL (vertex+fragment) con iluminacion Lambert (luz=camara), gestiona Framebuffer cacheado por id, dibuja MeshGpu con orbit camera, muestra via ImGui::Image y maneja drag (mouse) + wheel (zoom). Wireframe opcional via glPolygonMode. gl_loader: añade glUniformMatrix4fv (proc requerido en Windows para subir las matrices view/proj del mesh_viewer). issue 0029 --- cpp/functions/gfx/gl_loader.cpp | 2 + cpp/functions/gfx/gl_loader.h | 2 + cpp/functions/viz/mesh_viewer.cpp | 207 ++++++++++++++++++++++++++++++ cpp/functions/viz/mesh_viewer.h | 24 ++++ cpp/functions/viz/mesh_viewer.md | 55 ++++++++ 5 files changed, 290 insertions(+) create mode 100644 cpp/functions/viz/mesh_viewer.cpp create mode 100644 cpp/functions/viz/mesh_viewer.h create mode 100644 cpp/functions/viz/mesh_viewer.md diff --git a/cpp/functions/gfx/gl_loader.cpp b/cpp/functions/gfx/gl_loader.cpp index d0493b9c..2fc0ccca 100644 --- a/cpp/functions/gfx/gl_loader.cpp +++ b/cpp/functions/gfx/gl_loader.cpp @@ -31,6 +31,7 @@ PFNGLUNIFORM2FPROC fn_glUniform2f = nullptr; PFNGLUNIFORM3FPROC fn_glUniform3f = nullptr; PFNGLUNIFORM4FPROC fn_glUniform4f = nullptr; PFNGLUNIFORM4FVPROC fn_glUniform4fv = nullptr; +PFNGLUNIFORMMATRIX4FVPROC fn_glUniformMatrix4fv = nullptr; PFNGLUSEPROGRAMPROC fn_glUseProgram = nullptr; PFNGLACTIVETEXTUREPROC fn_glActiveTexture = nullptr; PFNGLGENERATEMIPMAPPROC fn_glGenerateMipmap = nullptr; @@ -82,6 +83,7 @@ bool gl_loader_init() { LOAD(glUniform3f); LOAD(glUniform4f); LOAD(glUniform4fv); + LOAD(glUniformMatrix4fv); LOAD(glUseProgram); LOAD(glActiveTexture); LOAD(glGenerateMipmap); diff --git a/cpp/functions/gfx/gl_loader.h b/cpp/functions/gfx/gl_loader.h index 499d1629..eeebda9a 100644 --- a/cpp/functions/gfx/gl_loader.h +++ b/cpp/functions/gfx/gl_loader.h @@ -37,6 +37,7 @@ extern PFNGLUNIFORM3FPROC fn_glUniform3f; extern PFNGLUNIFORM4FPROC fn_glUniform4f; extern PFNGLUNIFORM4FVPROC fn_glUniform4fv; + extern PFNGLUNIFORMMATRIX4FVPROC fn_glUniformMatrix4fv; extern PFNGLUSEPROGRAMPROC fn_glUseProgram; // Texture (gl_texture_load — issue 0026) extern PFNGLACTIVETEXTUREPROC fn_glActiveTexture; @@ -84,6 +85,7 @@ #define glUniform3f fn_glUniform3f #define glUniform4f fn_glUniform4f #define glUniform4fv fn_glUniform4fv + #define glUniformMatrix4fv fn_glUniformMatrix4fv #define glUseProgram fn_glUseProgram #define glActiveTexture fn_glActiveTexture #define glGenerateMipmap fn_glGenerateMipmap diff --git a/cpp/functions/viz/mesh_viewer.cpp b/cpp/functions/viz/mesh_viewer.cpp new file mode 100644 index 00000000..c6017b4c --- /dev/null +++ b/cpp/functions/viz/mesh_viewer.cpp @@ -0,0 +1,207 @@ +#include "viz/mesh_viewer.h" + +#include "gfx/gl_loader.h" +#include "gfx/gl_framebuffer.h" +#include "gfx/gl_shader.h" + +#include +#include + +namespace fn::viz { + +namespace { + +// Vertex+fragment shader with Lambert headlight (light = camera direction). +// We bypass gl_shader::compile_fragment because that helper assumes a +// fullscreen-quad pipeline; here we need explicit attribs and a vertex shader +// that consumes mvp + normalMatrix. + +const char* kVert = R"glsl( +#version 330 core +layout(location = 0) in vec3 a_pos; +layout(location = 1) in vec3 a_normal; + +uniform mat4 u_view; +uniform mat4 u_proj; + +out vec3 v_normal_view; + +void main() { + vec4 view_pos = u_view * vec4(a_pos, 1.0); + // For pure rotation in the view part of orbit-camera, the upper-left 3x3 + // suffices as the normal matrix. + v_normal_view = mat3(u_view) * a_normal; + gl_Position = u_proj * view_pos; +} +)glsl"; + +const char* kFrag = R"glsl( +#version 330 core +in vec3 v_normal_view; +out vec4 frag; + +uniform vec4 u_color; + +void main() { + // Headlight: light dir = +Z in view space (toward camera in right-handed view). + vec3 N = normalize(v_normal_view); + vec3 L = vec3(0.0, 0.0, 1.0); + float ndl = max(dot(N, L), 0.0); + // ambient 0.2 + diffuse 0.8 + float lighting = 0.2 + 0.8 * ndl; + frag = vec4(u_color.rgb * lighting, u_color.a); +} +)glsl"; + +// Per-id cached state. +struct Cache { + fn::gfx::Framebuffer fb{}; + GLuint program = 0; + GLint loc_view = -1; + GLint loc_proj = -1; + GLint loc_color = -1; + bool initialized = false; +}; + +std::unordered_map& caches() { + static std::unordered_map m; + return m; +} + +GLuint compile_program() { + // We need vertex+fragment, but gl_shader::compile_fragment only does + // fragment with a fixed vertex. Inline a small compile here. + auto compile = [](GLenum type, const char* src) -> GLuint { + GLuint sh = glCreateShader(type); + glShaderSource(sh, 1, &src, nullptr); + glCompileShader(sh); + GLint ok = 0; + glGetShaderiv(sh, GL_COMPILE_STATUS, &ok); + if (!ok) { + char log[512]; + glGetShaderInfoLog(sh, sizeof(log), nullptr, log); + (void)log; // could log via ImGui + glDeleteShader(sh); + return 0; + } + return sh; + }; + GLuint v = compile(GL_VERTEX_SHADER, kVert); + if (!v) return 0; + GLuint f = compile(GL_FRAGMENT_SHADER, kFrag); + if (!f) { glDeleteShader(v); return 0; } + GLuint p = glCreateProgram(); + glAttachShader(p, v); + glAttachShader(p, f); + glLinkProgram(p); + glDeleteShader(v); + glDeleteShader(f); + GLint ok = 0; + glGetProgramiv(p, GL_LINK_STATUS, &ok); + if (!ok) { glDeleteProgram(p); return 0; } + return p; +} + +void ensure_init(Cache& c) { + if (c.initialized) return; + fn::gfx::gl_loader_init(); + fn::gfx::fb_init(c.fb); + c.program = compile_program(); + if (c.program) { + c.loc_view = glGetUniformLocation(c.program, "u_view"); + c.loc_proj = glGetUniformLocation(c.program, "u_proj"); + c.loc_color = glGetUniformLocation(c.program, "u_color"); + } + c.initialized = true; +} + +} // namespace + +void mesh_viewer(const char* id, const MeshViewerConfig& cfg) { + if (!id) id = "##mesh_viewer"; + + // Resolve panel size. + ImVec2 size = cfg.size; + if (size.x <= 0) size.x = ImGui::GetContentRegionAvail().x; + if (size.y <= 0) size.y = 400.0f; + int w = (int)size.x; if (w < 1) w = 1; + int h = (int)size.y; if (h < 1) h = 1; + + auto& c = caches()[id]; + ensure_init(c); + + if (cfg.mesh && cfg.mesh->ok() && cfg.cam && c.program) { + cfg.cam->aspect = (float)w / (float)h; + + fn::gfx::fb_resize(c.fb, w, h); + + // Save GL state. + GLint prev_fbo = 0; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prev_fbo); + GLint prev_vp[4]; + glGetIntegerv(GL_VIEWPORT, prev_vp); + GLboolean prev_depth = glIsEnabled(GL_DEPTH_TEST); + + glBindFramebuffer(GL_FRAMEBUFFER, c.fb.fbo); + glViewport(0, 0, w, h); + glClearColor(0.10f, 0.10f, 0.13f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + // No depth attachment in our FBO — fall back to back-to-front-ish via + // GL_DEPTH_TEST off. For inspection meshes this is fine; documented. + glDisable(GL_DEPTH_TEST); + + glUseProgram(c.program); + auto m = fn::core::orbit_camera_matrices(*cfg.cam); + // Row-major -> upload with transpose=GL_TRUE. + if (c.loc_view >= 0) glUniformMatrix4fv(c.loc_view, 1, GL_TRUE, m.view); + if (c.loc_proj >= 0) glUniformMatrix4fv(c.loc_proj, 1, GL_TRUE, m.proj); + if (c.loc_color >= 0) { + float r = ((cfg.color >> 0) & 0xFF) / 255.0f; + float g = ((cfg.color >> 8) & 0xFF) / 255.0f; + float b = ((cfg.color >> 16) & 0xFF) / 255.0f; + float a = ((cfg.color >> 24) & 0xFF) / 255.0f; + glUniform4f(c.loc_color, r, g, b, a); + } + +#ifndef __EMSCRIPTEN__ + if (cfg.wireframe) { + glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); + } else { + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + } +#endif + + glBindVertexArray(cfg.mesh->vao); + glDrawElements(GL_TRIANGLES, cfg.mesh->index_count, GL_UNSIGNED_INT, 0); + glBindVertexArray(0); + +#ifndef __EMSCRIPTEN__ + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); +#endif + glUseProgram(0); + + // Restore GL state. + glBindFramebuffer(GL_FRAMEBUFFER, (GLuint)prev_fbo); + glViewport(prev_vp[0], prev_vp[1], prev_vp[2], prev_vp[3]); + if (prev_depth) glEnable(GL_DEPTH_TEST); + } + + // Display. + ImGui::Image((ImTextureID)(intptr_t)c.fb.tex, + ImVec2((float)w, (float)h), + ImVec2(0, 1), ImVec2(1, 0)); + + if (ImGui::IsItemActive() && cfg.cam) { + ImVec2 d = ImGui::GetIO().MouseDelta; + fn::core::orbit_camera_handle_drag(*cfg.cam, d, 0.0f); + } + if (ImGui::IsItemHovered() && cfg.cam) { + float w_scroll = ImGui::GetIO().MouseWheel; + if (w_scroll != 0.0f) { + ImVec2 zero{0, 0}; + fn::core::orbit_camera_handle_drag(*cfg.cam, zero, w_scroll); + } + } +} + +} // namespace fn::viz diff --git a/cpp/functions/viz/mesh_viewer.h b/cpp/functions/viz/mesh_viewer.h new file mode 100644 index 00000000..51964091 --- /dev/null +++ b/cpp/functions/viz/mesh_viewer.h @@ -0,0 +1,24 @@ +#pragma once + +#include "core/orbit_camera.h" +#include "gfx/mesh_gpu.h" + +#include "imgui.h" + +namespace fn::viz { + +struct MeshViewerConfig { + const fn::gfx::MeshGpu* mesh = nullptr; + fn::core::OrbitCamera* cam = nullptr; // mutable: drag/wheel handler + ImVec2 size = {-1.0f, 400.0f}; // -1 = stretch X + ImU32 color = IM_COL32(180, 180, 200, 255); + bool wireframe = false; +}; + +// Renderiza un MeshGpu en un FBO interno (cacheado por id) y muestra +// la textura via ImGui::Image. Maneja drag/wheel sobre el panel. +// +// El `id` debe ser estable para reusar el mismo FBO entre frames. +void mesh_viewer(const char* id, const MeshViewerConfig& cfg); + +} // namespace fn::viz diff --git a/cpp/functions/viz/mesh_viewer.md b/cpp/functions/viz/mesh_viewer.md new file mode 100644 index 00000000..dff986d3 --- /dev/null +++ b/cpp/functions/viz/mesh_viewer.md @@ -0,0 +1,55 @@ +--- +name: mesh_viewer +kind: component +lang: cpp +domain: viz +version: "1.0.0" +purity: impure +signature: "void mesh_viewer(const char* id, const MeshViewerConfig& cfg)" +description: "Renderiza un MeshGpu (3D) en un FBO interno cacheado por id, con orbit camera, iluminacion Lambert headlight, opcion wireframe. Drag/wheel del mouse mueven la camara." +tags: [imgui, opengl, mesh, 3d, viewer, viz, fbo] +uses_functions: [mesh_gpu_cpp_gfx, orbit_camera_cpp_core, gl_framebuffer_cpp_gfx] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [imgui, GL/gl.h] +tested: false +tests: [] +test_file_path: "" +file_path: "cpp/functions/viz/mesh_viewer.cpp" +framework: imgui +emits: ["camera_drag", "camera_zoom"] +output: "Renderiza una imagen del mesh dentro del frame ImGui actual; muta cfg.cam in-place segun drag/wheel del mouse cuando el panel esta active/hovered." +--- + +# mesh_viewer + +Componente de viz para inspeccionar geometria 3D dentro de cualquier panel ImGui. Internamente: + +1. Compila/cachea (por `id`) un programa shader Lambert headlight (vertex + fragment). +2. Cachea un `Framebuffer` por `id` y lo redimensiona segun `cfg.size`. +3. Cada frame: bind FBO, draw `cfg.mesh`, mostrar la textura via `ImGui::Image`. +4. Si el panel esta active → llama `orbit_camera_handle_drag` con `MouseDelta`. +5. Si el panel esta hovered y hay scroll → ajusta zoom. + +## Ejemplo + +```cpp +static fn::core::OrbitCamera cam; +fn::viz::MeshViewerConfig cfg{}; +cfg.mesh = &gpu; // MeshGpu valido +cfg.cam = &cam; +cfg.size = {-1, 480}; +cfg.wireframe = false; +cfg.color = IM_COL32(160, 200, 255, 255); +fn::viz::mesh_viewer("##teapot_view", cfg); +``` + +## Notas + +- **Sin depth buffer**: el FBO solo tiene attachment color (sigue el patron de `gl_framebuffer`). Para meshes complejos con auto-oclusion, esto produce artefactos. Issue futuro puede añadir depth/stencil renderbuffer. +- **Wireframe**: usa `glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)` (no disponible en GL ES; protegido con `#ifndef __EMSCRIPTEN__`). +- **Cache por id**: si el `id` cambia dinamicamente entre frames, se acumulan FBOs y programas en memoria (leak). Usar IDs estables. +- **Iluminacion**: Lambert con luz fija en `+Z` view-space ("headlight"), suficiente para inspeccion. Sin specular, sin sombras. +- **Matrices**: row-major desde `orbit_camera_matrices`; se suben con `transpose=GL_TRUE` (GL espera column-major). From 1fa82447c2f1d9e81e1a2a42d451058104a16394 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:27 +0200 Subject: [PATCH 5/6] feat(primitives_gallery): demo de mesh_viewer (cubo procedural + .obj loader) Genera cubo procedural in-line (mesh_obj_parse de string), permite cargar .obj desde un text input absoluto. Botones: Reload cube, Wireframe toggle, Load .obj. Status line con tris count y instrucciones (drag to orbit, wheel to zoom). issue 0029 --- cpp/apps/primitives_gallery/CMakeLists.txt | 6 ++ cpp/apps/primitives_gallery/demos.h | 1 + cpp/apps/primitives_gallery/demos_mesh.cpp | 108 +++++++++++++++++++++ cpp/apps/primitives_gallery/main.cpp | 1 + 4 files changed, 116 insertions(+) create mode 100644 cpp/apps/primitives_gallery/demos_mesh.cpp diff --git a/cpp/apps/primitives_gallery/CMakeLists.txt b/cpp/apps/primitives_gallery/CMakeLists.txt index 6812bf9f..ebd5cd0c 100644 --- a/cpp/apps/primitives_gallery/CMakeLists.txt +++ b/cpp/apps/primitives_gallery/CMakeLists.txt @@ -8,6 +8,7 @@ add_imgui_app(primitives_gallery demos_text_editor.cpp demos_gl_texture.cpp demos_extras.cpp + demos_mesh.cpp # text_editor + file_watcher (issue 0025) ${CMAKE_SOURCE_DIR}/functions/core/text_editor.cpp ${CMAKE_SOURCE_DIR}/functions/core/file_watcher.cpp @@ -55,6 +56,11 @@ add_imgui_app(primitives_gallery # gl_texture_load (issue 0026) + stb_image ${CMAKE_SOURCE_DIR}/functions/gfx/gl_texture_load.cpp ${CMAKE_SOURCE_DIR}/vendor/stb/stb_image_impl.cpp + # mesh_viewer stack (issue 0029) + ${CMAKE_SOURCE_DIR}/functions/gfx/mesh_obj_load.cpp + ${CMAKE_SOURCE_DIR}/functions/gfx/mesh_gpu.cpp + ${CMAKE_SOURCE_DIR}/functions/core/orbit_camera.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/mesh_viewer.cpp ) target_include_directories(primitives_gallery PRIVATE ${CMAKE_SOURCE_DIR}/vendor/imgui_text_edit diff --git a/cpp/apps/primitives_gallery/demos.h b/cpp/apps/primitives_gallery/demos.h index b7f22c32..c4168d42 100644 --- a/cpp/apps/primitives_gallery/demos.h +++ b/cpp/apps/primitives_gallery/demos.h @@ -34,6 +34,7 @@ void demo_candlestick(); void demo_gauge(); void demo_heatmap(); void demo_table_view(); +void demo_mesh_viewer(); // issue 0029 // --- Gfx --- void demo_shader_canvas(); diff --git a/cpp/apps/primitives_gallery/demos_mesh.cpp b/cpp/apps/primitives_gallery/demos_mesh.cpp new file mode 100644 index 00000000..5626f35e --- /dev/null +++ b/cpp/apps/primitives_gallery/demos_mesh.cpp @@ -0,0 +1,108 @@ +// Demo del primitivo viz/mesh_viewer. +// Genera un cubo procedural in-line, lo sube al GPU, y permite cargar un +// .obj desde un path ingresado en un text input. + +#include "demos.h" +#include "demo.h" + +#include "viz/mesh_viewer.h" +#include "gfx/mesh_obj_load.h" +#include "gfx/mesh_gpu.h" +#include "core/orbit_camera.h" + +#include +#include +#include + +namespace gallery { + +namespace { + +const char* kCubeObj = + "v -1 -1 -1\nv 1 -1 -1\nv 1 1 -1\nv -1 1 -1\n" + "v -1 -1 1\nv 1 -1 1\nv 1 1 1\nv -1 1 1\n" + "f 4 3 2 1\n" // back (-Z) — winding for outward normal + "f 5 6 7 8\n" // front (+Z) + "f 1 2 6 5\n" // bottom (-Y) + "f 8 7 3 4\n" // top (+Y) + "f 5 8 4 1\n" // left (-X) + "f 2 3 7 6\n"; // right (+X) + +struct State { + fn::gfx::MeshGpu mesh{}; + fn::core::OrbitCamera cam{}; + char path[512] = ""; + std::string status; + bool wireframe = false; + bool initialized = false; +}; + +State& state() { + static State s; + return s; +} + +void load_cube() { + auto& s = state(); + if (s.mesh.ok()) fn::gfx::mesh_gpu_destroy(s.mesh); + auto cpu = fn::gfx::mesh_obj_parse(kCubeObj, std::strlen(kCubeObj)); + s.mesh = fn::gfx::mesh_gpu_upload(cpu); + s.status = s.mesh.ok() + ? ("loaded cube: " + std::to_string(s.mesh.index_count / 3) + " tris") + : "cube upload failed"; +} + +void load_from_path() { + auto& s = state(); + if (!s.path[0]) { s.status = "path is empty"; return; } + auto cpu = fn::gfx::mesh_obj_load(s.path); + if (cpu.positions.empty()) { s.status = "parse/read failed"; return; } + if (s.mesh.ok()) fn::gfx::mesh_gpu_destroy(s.mesh); + s.mesh = fn::gfx::mesh_gpu_upload(cpu); + s.status = s.mesh.ok() + ? ("loaded: " + std::to_string(s.mesh.index_count / 3) + " tris") + : "upload failed"; +} + +} // namespace + +void demo_mesh_viewer() { + demo_header("mesh_viewer", "v1.0.0", + "Visualizador 3D para inspeccion de geometria. Composicion de " + "mesh_obj_load (parser .obj puro) + mesh_gpu (upload VAO/VBO/EBO) + " + "orbit_camera (drag/wheel) + mesh_viewer (FBO + ImGui::Image + Lambert)."); + + auto& s = state(); + if (!s.initialized) { + load_cube(); + s.initialized = true; + } + + // Controls row. + if (ImGui::Button("Reload cube")) load_cube(); + ImGui::SameLine(); + ImGui::Checkbox("Wireframe", &s.wireframe); + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(360); + ImGui::InputTextWithHint("##obj_path", "absolute path to .obj", s.path, sizeof(s.path)); + ImGui::SameLine(); + if (ImGui::Button("Load .obj")) load_from_path(); + + ImGui::TextDisabled("status: %s | tris: %d | drag to orbit, wheel to zoom", + s.status.c_str(), + s.mesh.ok() ? s.mesh.index_count / 3 : 0); + + ImGui::Separator(); + + fn::viz::MeshViewerConfig cfg{}; + cfg.mesh = &s.mesh; + cfg.cam = &s.cam; + cfg.size = ImVec2(-1.0f, 480.0f); + cfg.color = IM_COL32(160, 200, 255, 255); + cfg.wireframe = s.wireframe; + fn::viz::mesh_viewer("##gallery_mesh_viewer", cfg); +} + +} // namespace gallery diff --git a/cpp/apps/primitives_gallery/main.cpp b/cpp/apps/primitives_gallery/main.cpp index 269bfe6b..18f656df 100644 --- a/cpp/apps/primitives_gallery/main.cpp +++ b/cpp/apps/primitives_gallery/main.cpp @@ -61,6 +61,7 @@ static const DemoEntry k_demos[] = { {"gauge", "gauge", "Viz", &gallery::demo_gauge}, {"heatmap", "heatmap", "Viz", &gallery::demo_heatmap}, {"table_view", "table_view", "Viz", &gallery::demo_table_view}, + {"mesh_viewer", "mesh_viewer", "Viz", &gallery::demo_mesh_viewer}, // Gfx (shaders_lab core) {"shader_canvas", "shader_canvas", "Gfx", &gallery::demo_shader_canvas}, {"gl_texture", "gl_texture_load", "Gfx", &gallery::demo_gl_texture}, // wave 1 From 118015062b3b6592706a9d03a641a1bc125dcf5f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:32 +0200 Subject: [PATCH 6/6] =?UTF-8?q?chore(issues):=20cerrar=200029=20=E2=80=94?= =?UTF-8?q?=20mesh=5Fviewer=20+=20obj=20loader=20+=20orbit=5Fcamera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev/issues/{ => completed}/0029-cpp-mesh-viewer.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dev/issues/{ => completed}/0029-cpp-mesh-viewer.md (100%) diff --git a/dev/issues/0029-cpp-mesh-viewer.md b/dev/issues/completed/0029-cpp-mesh-viewer.md similarity index 100% rename from dev/issues/0029-cpp-mesh-viewer.md rename to dev/issues/completed/0029-cpp-mesh-viewer.md