From 580e4ba1fd7fdd799f1a63494b3db4c5d2913e31 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 25 Apr 2026 21:51:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(gfx):=20mesh=5Fobj=5Fload=20=E2=80=94=20mi?= =?UTF-8?q?nimal=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); +```