feat(gfx): mesh_obj_load — minimal Wavefront .obj parser
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
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
#include "gfx/mesh_obj_load.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
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<float> raw_pos; // stride 3
|
||||
std::vector<float> 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<Key> 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<FaceTri> 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<FaceVert> 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
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace fn::gfx {
|
||||
|
||||
// CPU-side mesh: positions interleaved with normals stride 3 floats each,
|
||||
// indexed by `indices`.
|
||||
struct Mesh {
|
||||
std::vector<float> positions; // x,y,z stride=3 (count = num_vertices*3)
|
||||
std::vector<float> normals; // x,y,z stride=3 (same length as positions)
|
||||
std::vector<uint32_t> 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
|
||||
@@ -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);
|
||||
```
|
||||
Reference in New Issue
Block a user