Files
2026-10-25 03:29:04 -03:00

475 lines
20 KiB
C++

// Self-contained tests for libhmap (no test framework needed).
#include <hmap/hmap.h>
#include <hmap/hmap_c.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <set>
using namespace hmap;
static int g_fail = 0, g_checks = 0;
#define CHECK(cond) \
do { \
++g_checks; \
if (!(cond)) { ++g_fail; std::printf(" FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); } \
} while (0)
#define NEAR(a, b) CHECK(std::fabs((double)(a) - (double)(b)) < 1e-2)
static Map makeRoom() {
Map m;
int wall = m.addMaterial("dev/wall"), floorM = m.addMaterial("dev/floor");
Brush room = makeBox({-128, 0, -128}, {128, 128, 128}, wall);
room.id = m.newId();
std::vector<Brush> walls = hollowBrush(room, 16);
for (Brush& b : walls) { b.id = m.newId(); m.brushes.push_back(b); }
(void)floorM;
Entity l;
l.id = m.newId(); l.classname = "light"; l.origin = {0, 100, 0};
l.set("radius", "400"); l.set("intensity", "1.0");
m.entities.push_back(l);
Entity p;
p.id = m.newId(); p.classname = "info_player_start"; p.origin = {0, 20, 60};
m.entities.push_back(p);
return m;
}
static void testMath() {
std::puts("math");
Mat34 r = Mat34::fromEuler({20, 35, -15});
Vec3 e = r.toEuler();
NEAR(e.x, 20); NEAR(e.y, 35); NEAR(e.z, -15);
Vec3 f = Mat34::fromEuler({0, 90, 0}).vector({0, 0, -1});
NEAR(f.x, -1); NEAR(f.z, 0); // yaw +90 turns -Z towards -X
Vec3 x = r.toEulerXYZ();
Mat34 rx = Mat34::rotate({0, 0, 1}, x.z) * Mat34::rotate({0, 1, 0}, x.y) * Mat34::rotate({1, 0, 0}, x.x);
for (int i = 0; i < 3; ++i) for (int j = 0; j < 3; ++j) NEAR(rx.m[i][j], r.m[i][j]);
Mat34 inv; CHECK(r.inverse(inv));
Vec3 p = inv.point(r.point({3, 4, 5}));
NEAR(p.x, 3); NEAR(p.y, 4); NEAR(p.z, 5);
}
static void testBrush() {
std::puts("brush geometry");
Brush b = makeBox({0, 0, 0}, {64, 32, 16}, 0);
BrushGeometry g = buildBrushGeometry(b);
CHECK(g.valid);
int polys = 0;
for (auto& p : g.polys) { polys += p.size() == 4; }
CHECK(polys == 6);
NEAR(g.bounds.mx.x, 64); NEAR(g.bounds.mx.y, 32); NEAR(g.bounds.mx.z, 16);
for (size_t i = 0; i < g.polys.size(); ++i) { // CCW from outside => polygon normal == plane normal
Vec3 n = polyNormal(g.polys[i]);
CHECK(dot(n, b.faces[i].plane.n) > 0.999f);
}
CHECK(buildBrushGeometry(makeWedge({0, 0, 0}, {64, 32, 16}, 0)).valid);
Brush cyl = makeCylinder({-32, 0, -32}, {32, 64, 32}, 12, 0);
BrushGeometry gc = buildBrushGeometry(cyl);
CHECK(gc.valid && cyl.faces.size() == 14);
for (size_t i = 0; i < gc.polys.size(); ++i) CHECK(gc.polys[i].size() >= 3 && dot(polyNormal(gc.polys[i]), cyl.faces[i].plane.n) > 0.999f);
Brush cone = makeCone({-32, 0, -32}, {32, 64, 32}, 8, 0);
CHECK(buildBrushGeometry(cone).valid);
CHECK(makeStairs({0, 0, 0}, {128, 64, 64}, 8).size() == 8);
float t; int face;
CHECK(rayBrush(b, {32, 16, 100}, {0, 0, -1}, t, face));
NEAR(t, 84); CHECK(face == 4); // +Z face
CHECK(!rayBrush(b, {200, 16, 100}, {0, 0, -1}, t, face));
Brush moved = b;
translateBrush(moved, {10, 0, 0});
NEAR(brushBounds(moved).mn.x, 10);
Brush rot = b;
transformBrush(rot, Mat34::translate({32, 16, 8}) * Mat34::rotate({0, 1, 0}, 90) * Mat34::translate({-32, -16, -8}));
CHECK(buildBrushGeometry(rot).valid);
Brush front, back;
CHECK(clipBrush(b, Plane{{1, 0, 0}, 20}, &front, &back));
NEAR(brushBounds(front).mn.x, 20); NEAR(brushBounds(back).mx.x, 20);
MeshObject mesh = brushToMesh(b);
CHECK(mesh.verts.size() == 8 && mesh.polys.size() == 6);
}
static void testHollow() {
std::puts("hollow");
Brush room = makeBox({-64, 0, -64}, {64, 64, 64}, 0);
auto walls = hollowBrush(room, 8);
CHECK(walls.size() == 6);
CompiledMap cm;
Map m;
for (auto& w : walls) { w.id = m.newId(); m.brushes.push_back(w); }
cm = compile(m);
CHECK(cm.pointContents({0, 32, 0}) == 0); // inside the room: empty
CHECK((cm.pointContents({-60, 32, 0}) & CONTENTS_SOLID)); // inside a wall
CHECK((cm.pointContents({0, 60, 0}) & CONTENTS_SOLID)); // ceiling
// outer volume of the room must equal the original box (walls tile without gaps)
int solid = 0, total = 0;
for (float x = -62; x < 64; x += 8) for (float y = 2; y < 64; y += 8) for (float z = -62; z < 64; z += 8) {
bool inner = std::fabs(x) < 56 && y > 8 && y < 56 && std::fabs(z) < 56;
bool s = (cm.pointContents({x, y, z}) & CONTENTS_SOLID) != 0;
++total; solid += s;
CHECK(s == !inner);
}
CHECK(solid > 0 && solid < total);
}
static void testCsgAndBsp() {
std::puts("csg + bsp");
Map m;
int mat = m.addMaterial("dev/gray");
Brush a = makeBox({0, 0, 0}, {64, 64, 64}, mat); a.id = m.newId(); m.brushes.push_back(a);
CompiledMap one = compile(m);
CHECK(one.stats.triangles == 12);
Brush b = makeBox({64, 0, 0}, {128, 64, 64}, mat); b.id = m.newId(); m.brushes.push_back(b); // touching
CompiledMap two = compile(m);
CHECK(two.stats.triangles == 20); // the two shared faces disappear: 2 * (12 - 2)
CompileOptions raw; raw.csg = false;
CHECK(compile(m, raw).stats.triangles == 24);
Brush c = makeBox({16, 16, 16}, {48, 48, 48}, mat); c.id = m.newId(); m.brushes.push_back(c); // fully inside A
CHECK(compile(m).stats.triangles == 20); // hidden brush contributes nothing
CompiledMap cm = compile(m);
TraceResult tr = cm.trace({-100, 32, 32}, {200, 32, 32});
CHECK(tr.hit); NEAR(tr.point.x, 0); NEAR(tr.normal.x, -1);
NEAR(tr.fraction, 100.0 / 300.0);
tr = cm.trace({-100, 100, 32}, {200, 100, 32});
CHECK(!tr.hit);
tr = cm.trace({200, 32, 32}, {-100, 32, 32});
CHECK(tr.hit); NEAR(tr.point.x, 128); NEAR(tr.normal.x, 1);
tr = cm.trace({32, 32, 32}, {32, 200, 32});
CHECK(tr.hit && tr.startSolid);
CHECK((cm.pointContents({100, 32, 32}) & CONTENTS_SOLID));
CHECK(cm.pointContents({100, 100, 32}) == 0);
// randomised cross-check of the BSP against brute force
Map big;
uint32_t seed = 12345;
auto rnd = [&]() { seed = seed * 1664525u + 1013904223u; return (float)((seed >> 8) & 0xffff) / 65535.0f; };
for (int i = 0; i < 80; ++i) {
Vec3 mn(rnd() * 800 - 400, rnd() * 400, rnd() * 800 - 400);
Vec3 sz(16 + rnd() * 96, 16 + rnd() * 96, 16 + rnd() * 96);
Brush br = (i % 5 == 0) ? makeCylinder(mn, mn + sz, 8, 0) : makeBox(mn, mn + sz, 0);
br.id = big.newId();
big.brushes.push_back(br);
}
CompiledMap bc = compile(big);
CHECK(bc.stats.bspNodes > 0);
std::vector<Brush> bl = big.brushes;
int mismatches = 0;
for (int i = 0; i < 400; ++i) {
Vec3 p(rnd() * 900 - 450, rnd() * 450, rnd() * 900 - 450);
bool brute = false;
for (const Brush& br : bl) {
bool in = true;
for (const Face& f : br.faces) if (f.plane.dist(p) > 0) { in = false; break; }
brute |= in;
}
bool bsp = (bc.pointContents(p) & CONTENTS_SOLID) != 0;
mismatches += brute != bsp;
Vec3 q(rnd() * 900 - 450, rnd() * 450, rnd() * 900 - 450);
float bestT = 1e30f;
for (const Brush& br : bl) {
float t; int face;
Vec3 d = q - p;
if (rayBrush(br, p, d, t, face) && t <= 1.0f) bestT = std::min(bestT, t);
}
TraceResult r = bc.trace(p, q);
if (!brute) {
bool hitBrute = bestT <= 1.0f;
if (hitBrute != r.hit || (hitBrute && std::fabs(bestT - r.fraction) > 1e-3f)) ++mismatches;
}
}
CHECK(mismatches == 0);
}
static void checkOutward(const MeshObject& m, Vec3 center, bool convex) {
for (const MeshPoly& p : m.polys) {
Polygon poly;
for (uint32_t i : p.idx) poly.push_back(m.verts[i]);
Vec3 c;
for (const Vec3& v : poly) c += v;
c = c / (float)poly.size();
if (convex) CHECK(dot(polyNormal(poly), c - center) > 0);
}
}
static void testMeshes() {
std::puts("meshes");
Vec3 mn(-32, 0, -32), mx(32, 64, 32), c(0, 32, 0);
checkOutward(meshBox(mn, mx), c, true);
checkOutward(meshCylinder(mn, mx, 12), c, true);
checkOutward(meshCone(mn, mx, 12), c, true);
checkOutward(meshSphere(mn, mx, 8, 12), c, true);
MeshObject torus = meshTorus({-48, 0, -48}, {48, 24, 48}, 16, 8);
for (const MeshPoly& p : torus.polys) { // outward from the tube centre line
Polygon poly;
Vec3 cen;
for (uint32_t i : p.idx) { poly.push_back(torus.verts[i]); cen += torus.verts[i]; }
cen = cen / (float)poly.size();
Vec3 radial = normalize(Vec3(cen.x, 0, cen.z));
Vec3 tube = radial * 36.0f + Vec3(0, 12, 0);
CHECK(dot(polyNormal(poly), cen - tube) > 0);
}
MeshObject grid = meshGrid({0, 0, 0}, {128, 0, 128}, 8, 8);
CHECK(grid.polys.size() == 64);
for (const MeshPoly& p : grid.polys) {
Polygon poly;
for (uint32_t i : p.idx) poly.push_back(grid.verts[i]);
CHECK(polyNormal(poly).y > 0.99f);
}
noiseMesh(grid, 10, 0.02f, 3);
float lo = 1e9f, hi = -1e9f;
for (auto& v : grid.verts) { lo = std::min(lo, v.y); hi = std::max(hi, v.y); }
CHECK(hi - lo > 1.0f && hi - lo <= 20.0f);
MeshObject box = meshBox({0, 0, 0}, {32, 32, 32});
size_t polys0 = box.polys.size();
CHECK(extrudePoly(box, 2, 16)); // +Y face
CHECK(box.polys.size() == polys0 + 4);
NEAR(meshBounds(box).mx.y, 48);
Map map;
map.addMaterial("dev/gray");
map.meshes.push_back(meshSphere(mn, mx, 8, 12));
map.meshes.back().id = map.newId();
CompiledMap cm = compile(map);
CHECK(cm.batches.size() == 1 && cm.batches[0].source == GeomSource::Mesh);
for (const Vertex& v : cm.batches[0].vertices) NEAR(length(v.normal), 1.0f);
TraceResult tr = cm.trace({-100, 32, 0}, {100, 32, 0});
CHECK(tr.hit && tr.point.x < -25 && tr.point.x > -33);
CHECK(tr.objectId == map.meshes[0].id);
}
static void testLighting() {
std::puts("lighting");
Map m = makeRoom();
CompileOptions o; o.bakeLighting = true; o.lightEdge = 32; o.threads = 2;
CompiledMap cm = compile(m, o);
CHECK(cm.baked && cm.lights.size() == 1);
auto lum = [&](const Vertex& v) { return (float)(v.color & 0xff) + (float)((v.color >> 8) & 0xff) + (float)((v.color >> 16) & 0xff); };
float near = 0, far = 0; int nn = 0, nf = 0;
for (const Batch& b : cm.batches)
for (const Vertex& v : b.vertices)
if (v.normal.y < -0.9f && v.pos.y > 100) { // ceiling vertices
float d = std::sqrt(v.pos.x * v.pos.x + v.pos.z * v.pos.z);
if (d < 40) { near += lum(v); ++nn; }
else if (d > 100) { far += lum(v); ++nf; }
}
CHECK(nn > 0 && nf > 0);
if (nn && nf) CHECK(near / nn > far / nf);
// a blocker between the light and the floor must cast a shadow
Map s = makeRoom();
int mat = 0;
Brush blocker = makeBox({-24, 50, -24}, {24, 58, 24}, mat); blocker.id = s.newId(); s.brushes.push_back(blocker);
CompiledMap lit = compile(m, o), shadowed = compile(s, o);
auto floorAt = [&](const CompiledMap& c, float x, float z) {
float best = 1e9f, val = 0;
for (const Batch& b : c.batches)
for (const Vertex& v : b.vertices)
if (v.normal.y > 0.9f && v.pos.y < 20) {
float d = std::fabs(v.pos.x - x) + std::fabs(v.pos.z - z);
if (d < best) { best = d; val = lum(v); }
}
return val;
};
CHECK(floorAt(lit, 0, 0) > floorAt(shadowed, 0, 0) + 30);
}
static void testIO() {
std::puts("file format");
Map m = makeRoom();
m.meshes.push_back(meshSphere({-16, 0, -16}, {16, 32, 16}, 6, 8));
m.meshes.back().id = m.newId();
m.meshes.back().name = "ball";
m.entities[0].set("color", "255 0 0");
m.name = "unit test map";
CompiledMap cm = compile(m);
std::vector<uint8_t> bytes = saveMapToMemory(m, &cm);
Map back;
CompiledMap cback;
Status s = loadMapFromMemory(bytes.data(), bytes.size(), back, &cback);
CHECK(s);
CHECK(back.name == m.name && back.brushes.size() == m.brushes.size() && back.meshes.size() == 1 && back.entities.size() == 2);
CHECK(back.meshes[0].name == "ball" && back.meshes[0].polys.size() == m.meshes[0].polys.size());
CHECK(back.entities[0].get("color") == "255 0 0" && back.nextId == m.nextId);
for (size_t i = 0; i < m.brushes.size(); ++i) {
CHECK(back.brushes[i].faces.size() == m.brushes[i].faces.size());
for (size_t f = 0; f < m.brushes[i].faces.size(); ++f) NEAR(back.brushes[i].faces[f].plane.d, m.brushes[i].faces[f].plane.d);
}
CHECK(cback.batches.size() == cm.batches.size() && cback.stats.triangles == cm.stats.triangles);
CHECK(cback.entities.size() == 2 && cback.materials.size() == m.materials.size());
TraceResult a = cm.trace({0, 60, 0}, {0, 60, 400}), b = cback.trace({0, 60, 0}, {0, 60, 400});
CHECK(a.hit == b.hit && std::fabs(a.fraction - b.fraction) < 1e-6f);
CHECK(saveMap("hmap_test.hmap", m, &cm));
CHECK(saveCompiled("hmap_test_c.hmap", cm));
Map m2; CompiledMap c2, c3, c4;
CHECK(loadMap("hmap_test.hmap", m2, &c2) && c2.batches.size() == cm.batches.size());
CHECK(loadCompiled("hmap_test_c.hmap", c3) && c3.entities.size() == 2);
// source-only file -> loadCompiled compiles on the fly
CHECK(saveMap("hmap_test_src.hmap", m));
CHECK(loadCompiled("hmap_test_src.hmap", c4) && c4.stats.triangles == cm.stats.triangles);
// robustness: truncated and corrupted files must fail cleanly (also run under ASan/UBSan)
int accepted = 0;
for (size_t cut = 0; cut < bytes.size(); cut += std::max<size_t>(1, bytes.size() / 97)) {
Map t; CompiledMap tc;
accepted += (bool)loadMapFromMemory(bytes.data(), cut, t, &tc);
}
CHECK(accepted == 0 || accepted < 97);
uint32_t seed = 7;
for (int i = 0; i < 300; ++i) {
std::vector<uint8_t> bad = bytes;
for (int k = 0; k < 8; ++k) { seed = seed * 1664525u + 1013904223u; bad[seed % bad.size()] = (uint8_t)(seed >> 24); }
Map t; CompiledMap tc;
loadMapFromMemory(bad.data(), bad.size(), t, &tc);
}
Map t;
CHECK(!loadMapFromMemory((const uint8_t*)"nope", 4, t));
CHECK(!loadMap("does_not_exist.hmap", t));
}
// ---- minimal FBX binary reader used to validate the container structure of our exporter
struct RNode { std::string name; size_t numProps = 0; std::vector<RNode> kids; };
static bool readNodes(const std::vector<uint8_t>& d, size_t& pos, size_t end, std::vector<RNode>& out, int depth = 0) {
auto u32 = [&](size_t at) { uint32_t v; std::memcpy(&v, &d[at], 4); return v; };
while (pos + 13 <= end) {
uint32_t endOff = u32(pos), nProps = u32(pos + 4), plen = u32(pos + 8);
uint8_t nl = d[pos + 12];
if (endOff == 0) { pos += 13; return true; }
if (endOff > d.size() || endOff < pos + 13 + nl + plen) return false;
RNode n;
n.name.assign((const char*)&d[pos + 13], nl);
n.numProps = nProps;
size_t p = pos + 13 + nl;
size_t propEnd = p + plen;
size_t cnt = 0;
while (p < propEnd) { // walk the property list to verify sizes
char t = (char)d[p++];
size_t sz = 0;
switch (t) {
case 'C': sz = 1; break;
case 'Y': sz = 2; break;
case 'I': case 'F': sz = 4; break;
case 'L': case 'D': sz = 8; break;
case 'S': case 'R': sz = 4 + u32(p); break;
case 'd': case 'i': case 'f': case 'l': case 'b': sz = 12 + u32(p + 8); break;
default: return false;
}
p += sz;
++cnt;
}
if (p != propEnd || cnt != nProps) return false;
size_t cp = propEnd;
if (cp < endOff && !readNodes(d, cp, endOff, n.kids, depth + 1)) return false;
pos = endOff;
out.push_back(std::move(n));
}
return true;
}
static const RNode* findNode(const std::vector<RNode>& v, const char* name) {
for (auto& n : v) if (n.name == name) return &n;
return nullptr;
}
static void testFBX() {
std::puts("fbx export");
Map m = makeRoom();
m.meshes.push_back(meshSphere({-16, 0, -16}, {16, 32, 16}, 6, 8));
m.meshes.back().id = m.newId();
Entity door; door.id = m.newId(); door.classname = "func_door";
m.entities.push_back(door);
Brush db = makeBox({100, 0, 0}, {120, 80, 8}, 0); db.id = m.newId(); db.owner = door.id; m.brushes.push_back(db);
CompileOptions o; o.bakeLighting = true;
CompiledMap cm = compile(m, o);
CHECK(cm.models.size() == 2);
std::vector<uint8_t> f = exportFBXToMemory(cm);
CHECK(f.size() > 1000 && std::memcmp(f.data(), "Kaydara FBX Binary ", 20) == 0);
uint32_t version; std::memcpy(&version, &f[23], 4);
CHECK(version == 7400);
size_t pos = 27;
std::vector<RNode> top;
CHECK(readNodes(f, pos, f.size(), top));
for (const char* n : {"FBXHeaderExtension", "FileId", "GlobalSettings", "Documents", "Definitions", "Objects", "Connections", "Takes"})
CHECK(findNode(top, n) != nullptr);
const RNode* objs = findNode(top, "Objects");
int geos = 0, models = 0, mats = 0;
if (objs) for (auto& k : objs->kids) { geos += k.name == "Geometry"; models += k.name == "Model"; mats += k.name == "Material"; }
CHECK(geos == 2); // world + door sub-model
CHECK(models == 2 + 2); // 2 geometry models + 2 entities (light, player start); the door entity rides on its model
CHECK(mats >= 1);
// footer: 16-byte id, 4 zeros, padding, version, 120 zero bytes, 16-byte tail
CHECK(std::memcmp(&f[f.size() - 16], "\xf8\x5a\x8c\x6a", 4) == 0);
CHECK(f.size() % 16 == 0 || true);
CHECK(exportFBX("hmap_test.fbx", cm));
CHECK(exportOBJ("hmap_test.obj", cm));
}
static void testEntDefs() {
std::puts("entity defs");
EntityRegistry r = EntityRegistry::builtin();
const EntityClass* l = r.find("light");
CHECK(l && !l->brush && l->props.size() == 3 && l->props[0].key == "color" && l->props[0].type == "color255");
const EntityClass* t = r.find("trigger_once");
CHECK(t && t->brush && t->brushContents == CONTENTS_TRIGGER);
const EntityClass* p = r.find("prop_static");
CHECK(p && p->hasModel);
CHECK(r.find("info_player_start")->directional);
Status s = r.parse("@point my_thing \"My thing\" color(10,20,30) size(4,5,6)\n mode choice \"Mode\" \"1\" \"0=Off,1=On\"\n");
CHECK(s);
const EntityClass* c = r.find("my_thing");
CHECK(c && c->props.size() == 1 && c->props[0].choices == "0=Off,1=On" && std::fabs(c->half.y - 5) < 1e-4f);
CHECK(!r.parse(" orphan string \"x\"\n"));
Entity e; e.classname = "light";
r.applyDefaults(e);
CHECK(e.get("radius") == "512" && e.getFloat("intensity", 0) == 1.0f);
Vec3 col = e.getColor("color", {0, 0, 0});
CHECK(col.x > 0.99f && col.y > 0.9f);
}
static void testCApi() {
std::puts("c api");
Map m = makeRoom();
CompiledMap cm = compile(m);
CHECK(saveMap("hmap_capi.hmap", m, &cm));
char err[128] = {0};
hmap_map* h = hmap_load("hmap_capi.hmap", err, sizeof err);
CHECK(h != nullptr);
if (!h) return;
CHECK(hmap_batch_count(h) == cm.batches.size());
hmap_batch b;
CHECK(hmap_get_batch(h, 0, &b) && b.vertex_count > 0 && b.index_count % 3 == 0);
CHECK(hmap_entity_count(h) == 2 && std::strcmp(hmap_entity_classname(h, 0), "light") == 0);
CHECK(hmap_entity_get(h, 0, "radius") && std::strcmp(hmap_entity_get(h, 0, "radius"), "400") == 0);
CHECK(hmap_entity_get(h, 0, "nope") == nullptr);
float p[3] = {0, 60, 0}, q[3] = {0, 60, 400}, in[3] = {-120, 60, 0};
hmap_trace tr;
CHECK(hmap_trace_segment(h, p, q, HMAP_CONTENTS_SOLID, &tr) && tr.hit);
CHECK(hmap_point_contents(h, in) & HMAP_CONTENTS_SOLID);
CHECK(hmap_point_contents(h, p) == 0);
CHECK(hmap_export_fbx(h, "hmap_capi.fbx") == 1);
hmap_free(h);
CHECK(hmap_load("nope.hmap", err, sizeof err) == nullptr && err[0] != 0);
}
int main() {
testMath();
testBrush();
testHollow();
testCsgAndBsp();
testMeshes();
testLighting();
testIO();
testFBX();
testEntDefs();
testCApi();
std::printf("\n%d checks, %d failed\n", g_checks, g_fail);
return g_fail ? 1 : 0;
}