#include "dc_level_mesh.h"
#include <cmath>
#include <algorithm>
namespace {
typedef CSG4Vec3 V3;
inline V3 sub(V3 a, V3 b) { return {a.x-b.x, a.y-b.y, a.z-b.z}; }
inline float dot(V3 a, V3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
inline V3 cross(V3 a, V3 b) {
return {a.y*b.z - a.z*b.y, a.z*b.x - a.x*b.z, a.x*b.y - a.y*b.x};
}
inline float len(V3 a) { return std::sqrt(dot(a,a)); }
} // namespace
void DCLevelMeshData::clear() {
positions.clear(); normals.clear(); material.clear();
proj.clear(); brush_id.clear(); indices.clear();
lo = hi = {0,0,0};
bounds_valid = false;
}
namespace dc_level_mesh {
void build(const std::vector<CSG4Face>& faces, DCLevelMeshData& out) {
out.clear();
// Upper bound: every winding contributes all its points, unwelded.
size_t pts = 0, tris = 0;
for (const CSG4Face& f : faces) {
if (f.pts.size() < 3) continue;
pts += f.pts.size();
tris += f.pts.size() - 2;
}
out.positions.reserve(pts * 3);
out.normals.reserve(pts * 3);
out.material.reserve(pts);
out.proj.reserve(pts);
out.brush_id.reserve(pts);
out.indices.reserve(tris * 3);
V3 lo{ 1e30f, 1e30f, 1e30f};
V3 hi{-1e30f, -1e30f, -1e30f};
bool any = false;
for (const CSG4Face& f : faces) {
const size_t n = f.pts.size();
if (n < 3) continue;
// The face's own plane normal, recovered from the winding. Using the
// winding (rather than re-reading the brush plane) keeps this function
// dependent only on `faces`, and makes the normal agree with the
// triangle orientation by construction — which is exactly what
// max_normal_deviation() then verifies.
V3 acc{0,0,0};
for (size_t i = 1; i + 1 < n; i++)
acc = { acc.x + cross(sub(f.pts[i], f.pts[0]), sub(f.pts[i+1], f.pts[0])).x,
acc.y + cross(sub(f.pts[i], f.pts[0]), sub(f.pts[i+1], f.pts[0])).y,
acc.z + cross(sub(f.pts[i], f.pts[0]), sub(f.pts[i+1], f.pts[0])).z };
const float al = len(acc);
if (al < 1e-20f) continue; // degenerate sliver — drop it
const V3 fn = { acc.x/al, acc.y/al, acc.z/al };
const int32_t base = (int32_t)(out.positions.size() / 3);
for (const V3& p : f.pts) {
out.positions.push_back(p.x);
out.positions.push_back(p.y);
out.positions.push_back(p.z);
out.normals.push_back(fn.x);
out.normals.push_back(fn.y);
out.normals.push_back(fn.z);
out.material.push_back(f.material_id);
out.proj.push_back(f.proj);
out.brush_id.push_back(f.brush_id);
lo.x = std::min(lo.x, p.x); hi.x = std::max(hi.x, p.x);
lo.y = std::min(lo.y, p.y); hi.y = std::max(hi.y, p.y);
lo.z = std::min(lo.z, p.z); hi.z = std::max(hi.z, p.z);
any = true;
}
// Fan triangulation, emitted in REVERSED winding order.
//
// csg4 orders each winding counter-clockwise about its outward plane
// normal, so the natural fan (base, i, i+1) gives a geometric normal of
// +fn. Godot's convention is the OPPOSITE: its front faces are clockwise
// viewed from the front, i.e. cross(v1-v0, v2-v0) points AWAY from the
// shading normal. [MEASURED] BoxMesh and SphereMesh both agree 0% with
// their own shading normals under that cross product; the un-reversed fan
// agreed 100%. Emitting (base, i+1, i) makes outward faces front-facing so
// the level renders solid from outside instead of inside-out.
//
// Positions and shading normals are untouched — only the triangle
// orientation changes, so lighting still uses the true outward normal.
// The collider path is unaffected: it hulls UNCLIPPED brush windings and
// ConvexPolygonShape3D derives its own hull, so point order is irrelevant.
for (size_t i = 1; i + 1 < n; i++) {
out.indices.push_back(base);
out.indices.push_back(base + (int32_t)i + 1);
out.indices.push_back(base + (int32_t)i);
}
}
if (any) { out.lo = lo; out.hi = hi; out.bounds_valid = true; }
}
void build_colliders(const std::vector<CSG4Brush>& brushes,
std::vector<DCConvexCollider>& out) {
out.clear();
out.reserve(brushes.size());
std::vector<V3> w;
for (const CSG4Brush& b : brushes) {
// Sky brushes are NON-SOLID: you can jump through them. They still reached
// csg4 and clipped their neighbours (they are real, visible geometry) —
// only the collider is skipped. This is the render/collide split the §10
// note already draws for the opposite reason: rendering uses the CLIPPED
// skin, collision the UNCLIPPED volume, and the two sets need not match.
if (b.sky) continue;
// Clip-only solids get NO collider. The carved remainder is the case:
// its volume must keep clipping neighbours, but a convex shape over it
// would re-solidify exactly the volume the carve removed — the player
// sees the hole and cannot walk into it.
if (b.clip_only) continue;
DCConvexCollider c;
c.brush_id = b.id;
std::vector<V3> hull;
// UNCLIPPED windings — collision is about volume, not visible surface.
for (uint32_t fi = 0; fi < b.plane_count; fi++) {
if (!dc_csg4::face_winding(b, fi, w)) continue;
for (const V3& p : w) {
bool dup = false;
for (const V3& q : hull)
if (len(sub(q, p)) < 1e-4f) { dup = true; break; }
if (!dup) hull.push_back(p);
}
}
if (hull.size() < 4) continue; // not a solid; nothing to collide with
c.points.reserve(hull.size() * 3);
for (const V3& p : hull) {
c.points.push_back(p.x);
c.points.push_back(p.y);
c.points.push_back(p.z);
}
out.push_back(std::move(c));
}
}
float max_normal_deviation(const DCLevelMeshData& m) {
float worst = 0.0f;
const uint32_t tris = m.triangle_count();
for (uint32_t t = 0; t < tris; t++) {
const int32_t i0 = m.indices[t*3 + 0];
const int32_t i1 = m.indices[t*3 + 1];
const int32_t i2 = m.indices[t*3 + 2];
const V3 p0{ m.positions[i0*3], m.positions[i0*3+1], m.positions[i0*3+2] };
const V3 p1{ m.positions[i1*3], m.positions[i1*3+1], m.positions[i1*3+2] };
const V3 p2{ m.positions[i2*3], m.positions[i2*3+1], m.positions[i2*3+2] };
const V3 g = cross(sub(p1, p0), sub(p2, p0));
const float gl = len(g);
if (gl < 1e-12f) continue; // degenerate triangle
// NEGATED: build() emits Godot-convention windings, where a front face's
// cross(v1-v0, v2-v0) points AWAY from the shading normal. So the EXPECTED
// relationship is gn == -sn, and -gn is what should align with sn.
//
// Keeping this orientation-aware (rather than using |dot|) is deliberate:
// it still measures how far each triangle tilts out of its source face's
// plane — the "sharp edges" number — AND it now fails loudly at ~180 deg if
// the winding is ever flipped again. An abs() here would have made this
// metric blind to the inversion that shipped in 0.768.0, which is exactly
// the hole that let a passing gate render inside-out.
const V3 gn{ -g.x/gl, -g.y/gl, -g.z/gl };
const V3 sn{ m.normals[i0*3], m.normals[i0*3+1], m.normals[i0*3+2] };
const float c = std::max(-1.0f, std::min(1.0f, dot(gn, sn)));
worst = std::max(worst, std::acos(c));
}
return worst;
}
} // namespace dc_level_mesh