// text_mesh.cpp // // Pipeline: // FreeType glyph outline → contour polygons (with Bezier flattening) // → classify contours as outer/hole by containment (not just winding) // → earcut triangulation per outer+holes group // → extrude into 3D solid (front face, back face, side walls) // // Dependencies: freetype2, earcut.hpp (header-only) #include "mesh.hpp" #include "thirdparty/earcut/earcut.hpp" #include "token.hpp" #include #include #include #include #include namespace Nimbin { #include FT_FREETYPE_H #include FT_OUTLINE_H static constexpr int BEZIER_STEPS = 8; static constexpr float FT_SCALE = 1.0f / 64.0f; using Pt2 = std::array; using Ring = DynArray; using Poly = DynArray; // [0]=outer, [1..]=holes namespace { struct FaceKey { std::string path; int size; bool operator==(const FaceKey& o) const { return size == o.size && path == o.path; } }; struct FaceKeyHash { size_t operator()(const FaceKey& k) const { return std::hash{}(k.path) ^ (std::hash{}(k.size) << 1); } }; FT_Library g_ft = nullptr; std::unordered_map g_faces; // Returns a ready-to-use face at the requested pixel size, or nullptr. FT_Face acquire_Face(const std::string& path, int size) { if (!g_ft && FT_Init_FreeType(&g_ft)) { g_ft = nullptr; return nullptr; } FaceKey key{ path, size }; auto it = g_faces.find(key); if (it != g_faces.end()) { FT_Set_Pixel_Sizes(it->second, 0, static_cast(size)); // cheap; face stays loaded return it->second; } FT_Face face; if (FT_New_Face(g_ft, path.c_str(), 0, &face)) { SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "FT_New_Face failed: %s", path.c_str()); return nullptr; } FT_Set_Pixel_Sizes(face, 0, static_cast(size)); g_faces.emplace(key, face); return face; } } // namespace // ── FreeType outline callbacks ──────────────────────────────────────────────── struct OutlineCtx { DynArray rings; Ring current; Pt2 pen{}; void flush() { if (current.size() >= 3) rings.push_back(std::move(current)); current.clear(); } }; static int ftMoveTo(const FT_Vector* to, void* user) { OutlineCtx* c = static_cast(user); c->flush(); c->pen = { static_cast(to->x)*FT_SCALE, static_cast(to->y)*FT_SCALE }; c->current.push_back(c->pen); return 0; } static int ftLineTo(const FT_Vector* to, void* user) { OutlineCtx* c = static_cast(user); c->pen = { static_cast(to->x)*FT_SCALE, static_cast(to->y)*FT_SCALE }; c->current.push_back(c->pen); return 0; } static int ftConicTo(const FT_Vector* ctrl, const FT_Vector* to, void* user) { OutlineCtx* c = static_cast(user); float p0x=c->pen[0], p0y=c->pen[1]; float p1x=static_cast(ctrl->x)*FT_SCALE, p1y=static_cast(ctrl->y)*FT_SCALE; float p2x=static_cast(to->x )*FT_SCALE, p2y=static_cast(to->y )*FT_SCALE; for (int i=1; i<=BEZIER_STEPS; i++) { float t=static_cast(i)/static_cast(BEZIER_STEPS), mt=1.0f-t; c->current.push_back({ mt*mt*p0x+2*mt*t*p1x+t*t*p2x, mt*mt*p0y+2*mt*t*p1y+t*t*p2y }); } c->pen = {p2x,p2y}; return 0; } static int ftCubicTo(const FT_Vector* c1, const FT_Vector* c2, const FT_Vector* to, void* user) { OutlineCtx* c = static_cast(user); float p0x=c->pen[0], p0y=c->pen[1]; float p1x=static_cast(c1->x)*FT_SCALE, p1y=static_cast(c1->y)*FT_SCALE; float p2x=static_cast(c2->x)*FT_SCALE, p2y=static_cast(c2->y)*FT_SCALE; float p3x=static_cast(to->x)*FT_SCALE, p3y=static_cast(to->y)*FT_SCALE; for (int i=1; i<=BEZIER_STEPS; i++) { float t=static_cast(i)/static_cast(BEZIER_STEPS), mt=1.0f-t; c->current.push_back({ mt*mt*mt*p0x+3*mt*mt*t*p1x+3*mt*t*t*p2x+t*t*t*p3x, mt*mt*mt*p0y+3*mt*mt*t*p1y+3*mt*t*t*p2y+t*t*t*p3y }); } c->pen = {p3x,p3y}; return 0; } static const FT_Outline_Funcs kFTFuncs = { ftMoveTo, ftLineTo, ftConicTo, ftCubicTo, 0, 0 }; // ── Contour helpers ─────────────────────────────────────────────────────────── // Signed area: positive = CCW (outer in FT Y-up), negative = CW (hole) static float signedArea(const Ring& r) { float a = 0.0f; size_t n = r.size(); for (size_t i = 0, j = n-1; i < n; j = i++) a += (r[j][0]+r[i][0]) * (r[j][1]-r[i][1]); return a * 0.5f; } // Axis-aligned bounding box of a ring struct AABB { float minx,miny,maxx,maxy; }; static AABB ringAABB(const Ring& r) { AABB b{ r[0][0],r[0][1],r[0][0],r[0][1] }; for (auto& p : r) { if (p[0]b.maxx) b.maxx=p[0]; if (p[1]b.maxy) b.maxy=p[1]; } return b; } // Does AABB A fully contain AABB B? static bool aabbContains(const AABB& a, const AABB& b) { return b.minx>=a.minx && b.maxx<=a.maxx && b.miny>=a.miny && b.maxy<=a.maxy; } // ── Build polygons: group contours into (outer + its holes) ────────────────── // FreeType guarantees: CCW = outer contour, CW = hole. // For glyphs like 'i', 'j', '!', there are multiple separate outer contours. // Each CW (hole) ring belongs to the outer ring whose AABB contains it. // If no outer contains it, treat it as its own outer (handles edge cases). static DynArray groupContours(DynArray& rings, float pen_x, float scale) { struct RingInfo { Ring r; AABB bb; bool is_hole; }; DynArray info; info.reserve(rings.size()); for (auto& ring : rings) { Ring shifted; shifted.reserve(ring.size()); for (auto& p : ring) shifted.push_back({ (p[0]+pen_x)*scale, p[1]*scale }); AABB bb = ringAABB(shifted); bool hole = (signedArea(ring) < 0.0f); // original coords for winding info.push_back({ std::move(shifted), bb, hole }); } // Collect outers DynArray outer_ids, hole_ids; for (size_t i = 0; i < info.size(); i++) (info[i].is_hole ? hole_ids : outer_ids).push_back(i); // Each outer starts its own Poly DynArray polys; for (size_t oi : outer_ids) { Poly p; p.push_back(info[oi].r); polys.push_back(std::move(p)); } // Assign each hole to the smallest containing outer for (size_t hi : hole_ids) { float best_area = 1e18f; int best_poly = -1; for (int pi = 0; pi < static_cast(polys.size()); pi++) { AABB ob = ringAABB(polys[static_cast(pi)][0]); if (aabbContains(ob, info[hi].bb)) { float a = (ob.maxx-ob.minx)*(ob.maxy-ob.miny); if (a < best_area) { best_area=a; best_poly=pi; } } } if (best_poly >= 0) polys[static_cast(best_poly)].push_back(info[hi].r); else { // No outer contains it — treat as standalone outer (shouldn't happen) Poly p; p.push_back(info[hi].r); polys.push_back(std::move(p)); } } return polys; } // ── Main builder ────────────────────────────────────────────────────────────── void computeVertexNormals(TextMesh& mesh) { mesh.vertex_normals.assign(mesh.verts.size(), Vec3{0,0,0}); for (const MeshTri& t : mesh.tris) { const Vec3& v0 = mesh.verts[static_cast(t.a)]; const Vec3& v1 = mesh.verts[static_cast(t.b)]; const Vec3& v2 = mesh.verts[static_cast(t.c)]; Vec3 e1 = v1 - v0; Vec3 e2 = v2 - v0; Vec3 n{ e1.y * e2.z - e1.z * e2.y, e1.z * e2.x - e1.x * e2.z, e1.x * e2.y - e1.y * e2.x }; // Accumulate weighted by area (len of cross product = 2*area) mesh.vertex_normals[static_cast(t.a)] += n; mesh.vertex_normals[static_cast(t.b)] += n; mesh.vertex_normals[static_cast(t.c)] += n; } for (Vec3& n : mesh.vertex_normals) { double len = SDL_sqrt(n.x*n.x + n.y*n.y + n.z*n.z); if (len > 0.000001) { n.x /= len; n.y /= len; n.z /= len; } else { n = {0, 0, -1}; // fallback: face forward } } } // ── Per-glyph geometry cache ────────────────────────────────────────────────── // A glyph's tessellated+extruded geometry depends only on (codepoint, scale) // now that depth is a draw-time Z-scale (mesh built at unit depth, faces ±0.5). // Build each glyph once; build() just stitches cached glyphs with an x-offset. namespace { struct GlyphKey { uint32_t ch; int scale_q; // scale quantised → stable float key bool operator==(const GlyphKey& o) const { return ch == o.ch && scale_q == o.scale_q; } }; struct GlyphKeyHash { size_t operator()(const GlyphKey& k) const { return std::hash{}(k.ch) * 1000003u ^ static_cast(k.scale_q); } }; struct GlyphMesh { TextMesh m; // verts/tris/vertex_normals/is_front, at origin, unit depth float advance = 0.0f; // scaled world-unit x advance }; std::unordered_map g_glyphs; // Extrude one glyph's polys into `out` at unit depth (faces at z = ±0.5). void extrude_Polys(TextMesh& out, const DynArray& polys) { constexpr float half_d = 0.5f; // UNIT depth — scaled per-instance at draw for (const Poly& poly : polys) { if (poly.empty() || poly[0].size() < 3) continue; DynArray indices = mapbox::earcut(poly); if (indices.empty()) continue; DynArray flat; for (auto& ring : poly) for (auto& p : ring) flat.push_back(p); int base = static_cast(out.verts.size()); for (auto& p : flat) { // front (z = -0.5) out.verts.push_back({ (double)p[0], (double)p[1], (double)(-half_d) }); out.is_front.push_back(1.0f); } int back_base = static_cast(out.verts.size()); for (auto& p : flat) { // back (z = +0.5) out.verts.push_back({ (double)p[0], (double)p[1], (double)( half_d) }); out.is_front.push_back(0.0f); } for (size_t i = 0; i + 2 < indices.size(); i += 3) out.tris.push_back({ base + (int)indices[i], base + (int)indices[i+2], base + (int)indices[i+1] }); for (size_t i = 0; i + 2 < indices.size(); i += 3) out.tris.push_back({ back_base + (int)indices[i], back_base + (int)indices[i+1], back_base + (int)indices[i+2] }); for (const auto& ring : poly) { // side walls int rn = static_cast(ring.size()); for (int i = 0; i < rn; i++) { int i1 = (i + 1) % rn; int wb = static_cast(out.verts.size()); const Pt2& p0 = ring[(size_t)i]; const Pt2& p1 = ring[(size_t)i1]; out.verts.push_back({ (double)p0[0], (double)p0[1], (double)(-half_d) }); out.is_front.push_back(0.0f); out.verts.push_back({ (double)p1[0], (double)p1[1], (double)(-half_d) }); out.is_front.push_back(0.0f); out.verts.push_back({ (double)p1[0], (double)p1[1], (double)( half_d) }); out.is_front.push_back(0.0f); out.verts.push_back({ (double)p0[0], (double)p0[1], (double)( half_d) }); out.is_front.push_back(0.0f); out.tris.push_back({ wb+0, wb+1, wb+2 }); out.tris.push_back({ wb+0, wb+2, wb+3 }); } } } } const GlyphMesh* acquire_Glyph(FT_Face face, uint32_t ch, float scale) { GlyphKey key{ ch, (int)SDL_lround((double)scale * 1e7) }; auto it = g_glyphs.find(key); if (it != g_glyphs.end()) return &it->second; // SDL_Log("GLYPH MISS '%c' scale_q=%d (cache=%zu)", (char)ch, key.scale_q, g_glyphs.size()); GlyphMesh gm; FT_UInt gi = FT_Get_Char_Index(face, (FT_ULong)ch); if (!FT_Load_Glyph(face, gi, FT_LOAD_NO_BITMAP) && face->glyph->format == FT_GLYPH_FORMAT_OUTLINE) { gm.advance = (float)face->glyph->advance.x * FT_SCALE * scale; OutlineCtx ctx; FT_Outline_Decompose(&face->glyph->outline, &kFTFuncs, &ctx); ctx.flush(); if (!ctx.rings.empty()) { DynArray polys = groupContours(ctx.rings, 0.0f, scale); // origin, scaled extrude_Polys(gm.m, polys); computeVertexNormals(gm.m); // per-glyph, once } } // failed load / non-outline → advance 0, no geometry (matches old skip) auto [ins, ok] = g_glyphs.emplace(std::move(key), std::move(gm)); return &ins->second; } } // namespace bool TextMesh::build(const std::string& font_path, const std::string& text, int size, float /*depth*/, float scale) { //SPIKE("mesh.build"); verts.clear(); tris.clear(); is_front.clear(); vertex_normals.clear(); FT_Face face = acquire_Face(font_path, size); if (!face) return false; float pen_x = 0.0f; // scaled world units for (const char chc : text) { const GlyphMesh* g = acquire_Glyph(face, (unsigned char)chc, scale); if (!g) continue; const int base = static_cast(verts.size()); for (const Vec3& v : g->m.verts) verts.push_back({ v.x + (double)pen_x, v.y, v.z }); // z stays unit (±0.5) for (const Vec3& n : g->m.vertex_normals) vertex_normals.push_back(n); for (float f : g->m.is_front) is_front.push_back(f); for (const MeshTri& t : g->m.tris) tris.push_back({ t.a + base, t.b + base, t.c + base }); pen_x += g->advance; } // Centre on bounding box (x only) — unchanged. if (!verts.empty()) { float minX = 1e9f, maxX = -1e9f; for (const Vec3& v : verts) { float vx = (float)v.x; if (vx < minX) minX = vx; if (vx > maxX) maxX = vx; } double cx = (double)((minX + maxX) * 0.5f); for (Vec3& v : verts) v.x -= cx; } // Normals are cached per glyph — no whole-word recompute needed. return !verts.empty(); } } // namespace Nimbin