chore: auto-commit (43 archivos)

- .mcp.json
- bash/functions/infra/write_mcp_jupyter_config.md
- bash/functions/infra/write_mcp_jupyter_config.sh
- cpp/CMakeLists.txt
- cpp/apps/chart_demo
- cpp/apps/shaders_lab
- cpp/functions/gfx/gl_framebuffer.cpp
- cpp/functions/gfx/gl_framebuffer.h
- cpp/functions/gfx/gl_framebuffer.md
- cpp/functions/gfx/mesh_gpu.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 17:28:47 +02:00
parent a2efdcf003
commit fd5787c55f
44 changed files with 3924 additions and 64 deletions
+7
View File
@@ -317,6 +317,13 @@ add_fn_test(test_sse_client test_sse_client.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
# --- gltf_load_mesh: GLB 2.0 parser puro (CPU, sin GL) ---
# Incluimos nlohmann desde cpp/vendor/. El parser no necesita GL ni imgui.
add_fn_test(test_gltf_load_mesh test_gltf_load_mesh.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gltf_load_mesh.cpp)
target_include_directories(test_gltf_load_mesh PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../vendor)
# --- Issue 0132 — ansi_parser: logica pura, sin ImGui ---
add_fn_test(test_ansi_parser test_ansi_parser.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp)
+243
View File
@@ -0,0 +1,243 @@
// Unit tests para gltf_load_mesh (issue: gltf_load_mesh_cpp_gfx).
// Cubre: reject magic invalido, triangulo con POSITION+indices sin NORMAL
// (normales generadas correctamente), quad (2 tris), load desde memoria.
// No requiere contexto GL — logica CPU pura.
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "gfx/gltf_load_mesh.h"
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
// ---------------------------------------------------------------------------
// GLB builder helpers (minimal — construye GLB en memoria para tests)
// ---------------------------------------------------------------------------
// nlohmann is in vendor/ but test includes functions/ and framework/ by default.
// Build a GLB manually via byte helpers to avoid adding another include path.
static void write_u32le(std::vector<uint8_t>& buf, uint32_t v) {
buf.push_back(v & 0xFF);
buf.push_back((v >> 8) & 0xFF);
buf.push_back((v >> 16) & 0xFF);
buf.push_back((v >> 24) & 0xFF);
}
static void write_f32le(std::vector<uint8_t>& buf, float v) {
uint32_t u; std::memcpy(&u, &v, 4);
write_u32le(buf, u);
}
// Align 'buf' to 4-byte boundary by appending 0x20 (space) padding.
static void align4(std::vector<uint8_t>& buf) {
while (buf.size() % 4 != 0) buf.push_back(0x20);
}
// Build a minimal GLB with:
// positions: flat float xyz array (length = nv*3)
// indices: uint16 array (length = ni)
// normals: optional float xyz array (length = nv*3, nullptr = omit)
// Returns the complete GLB byte vector.
static std::vector<uint8_t> build_glb(const float* positions, size_t nv,
const uint16_t* indices, size_t ni,
const float* normals = nullptr) {
// BIN chunk: positions | indices | (normals)
std::vector<uint8_t> bin;
size_t pos_offset = 0;
size_t pos_byteLen = nv * 3 * 4;
for (size_t i = 0; i < nv*3; ++i) write_f32le(bin, positions[i]);
// pad before indices so they start at 4-byte alignment
while (bin.size() % 4 != 0) bin.push_back(0);
size_t idx_offset = bin.size();
size_t idx_byteLen = ni * 2;
for (size_t i = 0; i < ni; ++i) {
bin.push_back(indices[i] & 0xFF);
bin.push_back((indices[i] >> 8) & 0xFF);
}
size_t nrm_offset = 0, nrm_byteLen = 0;
if (normals) {
while (bin.size() % 4 != 0) bin.push_back(0);
nrm_offset = bin.size();
nrm_byteLen = nv * 3 * 4;
for (size_t i = 0; i < nv*3; ++i) write_f32le(bin, normals[i]);
}
// GLB chunk length must be multiple of 4
while (bin.size() % 4 != 0) bin.push_back(0);
// Build JSON
// accessor 0: POSITION (vec3 float, bufferView 0)
// accessor 1: indices (scalar uint16, bufferView 1)
// accessor 2: NORMAL (vec3 float, bufferView 2) — if normals present
std::string json = "{";
json += "\"asset\":{\"version\":\"2.0\"},";
json += "\"buffers\":[{\"byteLength\":" + std::to_string(bin.size()) + "}],";
// bufferViews
json += "\"bufferViews\":[";
json += "{\"buffer\":0,\"byteOffset\":" + std::to_string(pos_offset) +
",\"byteLength\":" + std::to_string(pos_byteLen) + "}";
json += ",{\"buffer\":0,\"byteOffset\":" + std::to_string(idx_offset) +
",\"byteLength\":" + std::to_string(idx_byteLen) + "}";
if (normals) {
json += ",{\"buffer\":0,\"byteOffset\":" + std::to_string(nrm_offset) +
",\"byteLength\":" + std::to_string(nrm_byteLen) + "}";
}
json += "],";
// accessors
json += "\"accessors\":[";
json += "{\"bufferView\":0,\"byteOffset\":0,\"componentType\":5126,\"count\":" +
std::to_string(nv) + ",\"type\":\"VEC3\"}";
json += ",{\"bufferView\":1,\"byteOffset\":0,\"componentType\":5123,\"count\":" +
std::to_string(ni) + ",\"type\":\"SCALAR\"}";
if (normals) {
json += ",{\"bufferView\":2,\"byteOffset\":0,\"componentType\":5126,\"count\":" +
std::to_string(nv) + ",\"type\":\"VEC3\"}";
}
json += "],";
// meshes / primitives
std::string attrs = "\"POSITION\":0";
if (normals) attrs += ",\"NORMAL\":2";
json += "\"meshes\":[{\"primitives\":[{\"attributes\":{" + attrs + "},\"indices\":1}]}]";
json += "}";
// Pad JSON to 4-byte boundary
while (json.size() % 4 != 0) json += ' ';
// Assemble GLB
std::vector<uint8_t> glb;
uint32_t json_chunk_len = (uint32_t)json.size();
uint32_t bin_chunk_len = (uint32_t)bin.size();
uint32_t total = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
// Header
write_u32le(glb, 0x46546C67u); // magic "glTF"
write_u32le(glb, 2u); // version
write_u32le(glb, total);
// Chunk 0: JSON
write_u32le(glb, json_chunk_len);
write_u32le(glb, 0x4E4F534Au); // "JSON"
for (char c : json) glb.push_back((uint8_t)c);
// Chunk 1: BIN
write_u32le(glb, bin_chunk_len);
write_u32le(glb, 0x004E4942u); // "BIN\0"
for (uint8_t b : bin) glb.push_back(b);
return glb;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
TEST_CASE("invalid magic -> empty Mesh + last_error set", "[gltf][reject]") {
std::vector<uint8_t> bad(12, 0);
bad[0] = 0xDE; bad[1] = 0xAD; bad[2] = 0xBE; bad[3] = 0xEF;
// version=2, total=12
bad[4]=2; bad[8]=12;
auto m = fn::gfx::gltf_load_mesh_from_memory(bad.data(), bad.size());
REQUIRE(m.positions.empty());
REQUIRE(m.indices.empty());
std::string err = fn::gfx::gltf_load_last_error();
REQUIRE(!err.empty());
INFO("last_error: " << err);
// Should mention magic or "not a GLB"
REQUIRE((err.find("magic") != std::string::npos ||
err.find("GLB") != std::string::npos));
}
TEST_CASE("too-small buffer -> empty Mesh + last_error set", "[gltf][reject]") {
std::vector<uint8_t> tiny = {0x67, 0x6C, 0x54, 0x46}; // only 4 bytes
auto m = fn::gfx::gltf_load_mesh_from_memory(tiny.data(), tiny.size());
REQUIRE(m.positions.empty());
std::string err = fn::gfx::gltf_load_last_error();
REQUIRE(!err.empty());
}
TEST_CASE("triangle without NORMAL -> normals generated, correct count", "[gltf][triangle][normals]") {
// One triangle in XY plane (z=0): (0,0,0), (1,0,0), (0,1,0)
// Face normal = (0,0,1) → all vertices should get approx (0,0,1)
float pos[] = { 0,0,0, 1,0,0, 0,1,0 };
uint16_t idx[] = { 0, 1, 2 };
auto glb = build_glb(pos, 3, idx, 3, /*normals=*/nullptr);
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
REQUIRE(m.positions.size() == 9); // 3 vertices * 3 floats
REQUIRE(m.indices.size() == 3);
REQUIRE(m.normals.size() == m.positions.size());
// Check positions
REQUIRE(m.positions[0] == Catch::Approx(0.0f));
REQUIRE(m.positions[1] == Catch::Approx(0.0f));
REQUIRE(m.positions[2] == Catch::Approx(0.0f));
REQUIRE(m.positions[3] == Catch::Approx(1.0f));
REQUIRE(m.positions[6] == Catch::Approx(0.0f));
REQUIRE(m.positions[7] == Catch::Approx(1.0f));
// Check indices
REQUIRE(m.indices[0] == 0u);
REQUIRE(m.indices[1] == 1u);
REQUIRE(m.indices[2] == 2u);
// Generated normals should point toward +Z for all 3 vertices
for (int v = 0; v < 3; ++v) {
REQUIRE(m.normals[v*3+0] == Catch::Approx(0.0f).margin(1e-5f));
REQUIRE(m.normals[v*3+1] == Catch::Approx(0.0f).margin(1e-5f));
REQUIRE(m.normals[v*3+2] == Catch::Approx(1.0f).margin(1e-5f));
}
}
TEST_CASE("quad (2 triangles) -> positions.size()==12, indices.size()==6", "[gltf][quad]") {
// Quad in XY: (0,0,0),(1,0,0),(1,1,0),(0,1,0) split into 2 tris
float pos[] = { 0,0,0, 1,0,0, 1,1,0, 0,1,0 };
uint16_t idx[] = { 0,1,2, 0,2,3 };
auto glb = build_glb(pos, 4, idx, 6, nullptr);
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
REQUIRE(m.positions.size() == 12); // 4 * 3
REQUIRE(m.normals.size() == 12);
REQUIRE(m.indices.size() == 6);
// All normals should be (0,0,1) — flat XY plane
for (int v = 0; v < 4; ++v) {
REQUIRE(m.normals[v*3+2] == Catch::Approx(1.0f).margin(1e-5f));
}
}
TEST_CASE("explicit normals -> passed through unchanged", "[gltf][normals]") {
float pos[] = { 0,0,0, 1,0,0, 0,1,0 };
uint16_t idx[] = { 0, 1, 2 };
// Provide normals pointing in -Z (unusual, but should be respected)
float nrm[] = { 0,0,-1, 0,0,-1, 0,0,-1 };
auto glb = build_glb(pos, 3, idx, 3, nrm);
auto m = fn::gfx::gltf_load_mesh_from_memory(glb.data(), glb.size());
REQUIRE(m.positions.size() == 9);
REQUIRE(m.normals.size() == 9);
for (int v = 0; v < 3; ++v) {
REQUIRE(m.normals[v*3+0] == Catch::Approx(0.0f).margin(1e-5f));
REQUIRE(m.normals[v*3+1] == Catch::Approx(0.0f).margin(1e-5f));
REQUIRE(m.normals[v*3+2] == Catch::Approx(-1.0f).margin(1e-5f));
}
}
TEST_CASE("nonexistent file -> empty Mesh + last_error set", "[gltf][file]") {
auto m = fn::gfx::gltf_load_mesh_from_file("/tmp/does_not_exist_gltf_test_abc123.glb");
REQUIRE(m.positions.empty());
std::string err = fn::gfx::gltf_load_last_error();
REQUIRE(!err.empty());
}