Initial release
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# Example: inspect a .dinodata DINOv3 conditioning file.
|
||||
add_executable(dino_info dino_info.cpp)
|
||||
target_link_libraries(dino_info PRIVATE trellis2)
|
||||
target_compile_features(dino_info PRIVATE cxx_std_14)
|
||||
|
||||
# Example: inspect a converted SS-flow DiT GGUF (hparams + tensor inventory).
|
||||
add_executable(ss_flow_info ss_flow_info.cpp)
|
||||
target_link_libraries(ss_flow_info PRIVATE trellis2)
|
||||
target_compile_features(ss_flow_info PRIVATE cxx_std_14)
|
||||
|
||||
# Example: stage-1 sampling, .dinodata cond -> sparse-structure latent z_s.
|
||||
add_executable(ss_sample ss_sample.cpp)
|
||||
target_link_libraries(ss_sample PRIVATE trellis2)
|
||||
target_compile_features(ss_sample PRIVATE cxx_std_14)
|
||||
|
||||
# Example: stage-1 decoding, z_s latent -> occupancy logit grid (64^3).
|
||||
add_executable(ss_decode ss_decode.cpp)
|
||||
target_link_libraries(ss_decode PRIVATE trellis2)
|
||||
target_compile_features(ss_decode PRIVATE cxx_std_14)
|
||||
|
||||
# Example: decode + extract the occupancy isosurface as an OBJ (marching cubes).
|
||||
add_executable(ss_mesh ss_mesh.cpp)
|
||||
target_link_libraries(ss_mesh PRIVATE trellis2)
|
||||
target_compile_features(ss_mesh PRIVATE cxx_std_14)
|
||||
|
||||
# Example: image -> preprocessed 512x512 -> DINOv3 conditioning (.dinodata).
|
||||
add_executable(dino_encode dino_encode.cpp)
|
||||
target_link_libraries(dino_encode PRIVATE trellis2)
|
||||
target_compile_features(dino_encode PRIVATE cxx_std_14)
|
||||
|
||||
# Example: export a demo mesh (T2MESH01/02/03) into a portable GLB (no GPU).
|
||||
add_executable(mesh2glb mesh2glb.cpp)
|
||||
target_link_libraries(mesh2glb PRIVATE trellis2)
|
||||
target_compile_features(mesh2glb PRIVATE cxx_std_14)
|
||||
@@ -0,0 +1,97 @@
|
||||
// Encode an image into the TRELLIS.2 DINOv3 conditioning tensor (.dinodata),
|
||||
// replacing the external dump_dinodata.py:
|
||||
//
|
||||
// image (PNG/JPG; solid black/white backgrounds are removed automatically)
|
||||
// -> background cleanup -> alpha bbox crop, premultiply, LANCZOS 512
|
||||
// -> DINOv3 ViT-L/16 -> [1, 1029, 1024] cond -> .dinodata
|
||||
//
|
||||
// usage: dino_encode <dino.gguf> <image> [out.dinodata] [--size N] [--pre out.png]
|
||||
//
|
||||
#include "trellis2.h"
|
||||
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#include "stb_image.h"
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include "stb_image_write.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 3) {
|
||||
std::fprintf(stderr,
|
||||
"usage: %s <dino.gguf> <image> [out.dinodata] [--size N] [--pre out.png]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const std::string gguf_path = argv[1];
|
||||
const std::string img_path = argv[2];
|
||||
std::string out_path = "cond.dinodata";
|
||||
std::string pre_path;
|
||||
int size = 512;
|
||||
for (int i = 3; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--size") == 0 && i + 1 < argc) {
|
||||
size = std::atoi(argv[++i]);
|
||||
} else if (std::strcmp(argv[i], "--pre") == 0 && i + 1 < argc) {
|
||||
pre_path = argv[++i];
|
||||
} else {
|
||||
out_path = argv[i];
|
||||
}
|
||||
}
|
||||
|
||||
int w = 0, h = 0, comp = 0;
|
||||
unsigned char * pixels = stbi_load(img_path.c_str(), &w, &h, &comp, 4);
|
||||
if (!pixels) {
|
||||
std::fprintf(stderr, "failed to decode image %s: %s\n",
|
||||
img_path.c_str(), stbi_failure_reason());
|
||||
return 1;
|
||||
}
|
||||
const int removed = trellis2_remove_solid_background_rgba(
|
||||
pixels, w, h, TRELLIS2_BACKGROUND_AUTO);
|
||||
std::printf("image : %s %dx%d (%d channels, background pixels changed: %d)\n",
|
||||
img_path.c_str(), w, h, comp, removed);
|
||||
|
||||
std::string err;
|
||||
std::vector<uint8_t> rgb;
|
||||
if (!trellis2_preprocess_rgba(pixels, w, h, size, rgb, &err)) {
|
||||
std::fprintf(stderr, "preprocess failed: %s\n", err.c_str());
|
||||
stbi_image_free(pixels);
|
||||
return 1;
|
||||
}
|
||||
stbi_image_free(pixels);
|
||||
|
||||
if (!pre_path.empty()) {
|
||||
stbi_write_png(pre_path.c_str(), size, size, 3, rgb.data(), size * 3);
|
||||
std::printf("wrote : %s (preprocessed %dx%d RGB)\n", pre_path.c_str(), size, size);
|
||||
}
|
||||
|
||||
trellis2_dino_model * model = trellis2_dino_load(gguf_path, true, &err);
|
||||
if (!model) {
|
||||
std::fprintf(stderr, "model load failed: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("model : %s (backend %s)\n", gguf_path.c_str(),
|
||||
trellis2_dino_backend_name(model));
|
||||
|
||||
trellis2_dino_cond cond;
|
||||
if (!trellis2_dino_encode_rgb(model, rgb.data(), size, cond, &err)) {
|
||||
std::fprintf(stderr, "encode failed: %s\n", err.c_str());
|
||||
trellis2_dino_free(model);
|
||||
return 1;
|
||||
}
|
||||
trellis2_dino_free(model);
|
||||
|
||||
const trellis2_dino_fingerprint fp = trellis2_dino_fingerprints(cond);
|
||||
std::printf("cond : [1, %lld, %lld] min=%.4f max=%.4f mean=%.6f l2=%.4f\n",
|
||||
(long long) cond.tokens(), (long long) cond.channels(),
|
||||
fp.vmin, fp.vmax, fp.mean, fp.l2);
|
||||
|
||||
if (!trellis2_save_dinodata(out_path, cond, &err)) {
|
||||
std::fprintf(stderr, "save failed: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("wrote : %s (%zu floats)\n", out_path.c_str(), cond.count());
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// dino_info — load a .dinodata DINOv3 conditioning file and print its shape,
|
||||
// token breakdown, and fingerprints. The fingerprints should match the values
|
||||
// in the matching `<stem>.dino.txt` JSON sidecar bit-for-bit.
|
||||
//
|
||||
// usage: dino_info <path-to.dinodata>
|
||||
|
||||
#include "trellis2.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 2) {
|
||||
std::fprintf(stderr, "usage: %s <path-to.dinodata>\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
|
||||
std::printf("trellis2.cpp %s\n", trellis2_version());
|
||||
|
||||
const std::string path = argv[1];
|
||||
trellis2_dino_cond cond;
|
||||
std::string err;
|
||||
if (!trellis2_load_dinodata(path, cond, &err)) {
|
||||
std::fprintf(stderr, "error: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::printf("file : %s\n", path.c_str());
|
||||
std::printf("format version : %u\n", cond.format_version);
|
||||
|
||||
std::printf("shape : [");
|
||||
for (size_t i = 0; i < cond.shape.size(); ++i) {
|
||||
std::printf("%lld%s", (long long) cond.shape[i],
|
||||
i + 1 < cond.shape.size() ? ", " : "");
|
||||
}
|
||||
std::printf("]\n");
|
||||
|
||||
const long long tok = (long long) cond.tokens();
|
||||
std::printf("tokens : %lld (cls:1 + register:4 + patch:%lld)\n",
|
||||
tok, tok >= 5 ? tok - 5 : 0);
|
||||
std::printf("channels : %lld\n", (long long) cond.channels());
|
||||
std::printf("count : %zu floats\n", cond.count());
|
||||
|
||||
const trellis2_dino_fingerprint fp = trellis2_dino_fingerprints(cond);
|
||||
std::printf("fingerprints:\n");
|
||||
std::printf(" min : %.6f\n", fp.vmin);
|
||||
std::printf(" max : %.6f\n", fp.vmax);
|
||||
std::printf(" mean : %.9f\n", fp.mean);
|
||||
std::printf(" sum : %.6f\n", fp.sum);
|
||||
std::printf(" l2 : %.6f\n", fp.l2);
|
||||
std::printf(" count : %zu\n", fp.count);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// flexible_dual_grid.h — single-header CPU port of TRELLIS.2's flexible dual
|
||||
// grid mesh extraction (o-voxel/o_voxel/convert/flexible_dual_grid.py, eval
|
||||
// path). Turns the shape decoder's per-voxel 7-channel output into a triangle
|
||||
// mesh, replacing the CUDA hashmap kernel with an std::unordered_map.
|
||||
//
|
||||
// Per active voxel v at integer coord c (in [0, grid_size)):
|
||||
// dual vertex V_v = (c + offset_v) * voxel_size + aabb0 (unit cube here)
|
||||
// offset_v = (1 + 2*margin) * sigmoid(feat[0:3]) - margin
|
||||
// intersected feat[3:6] > 0, one flag per axis (x, y, z)
|
||||
// split_weight softplus(feat[6])
|
||||
// For each voxel with an intersected axis, the 4 voxels around that edge
|
||||
// (offsets below) contribute their dual vertices as a quad; if all 4 exist the
|
||||
// quad is split into 2 triangles along the diagonal chosen by the decoder's
|
||||
// learned split_weight = softplus(feat[6]) (reference eval-path tie-break).
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace fdg {
|
||||
|
||||
struct Mesh {
|
||||
std::vector<float> verts; // 3 per vertex
|
||||
std::vector<int> tris; // 3 indices per triangle
|
||||
size_t n_verts() const { return verts.size() / 3; }
|
||||
size_t n_tris() const { return tris.size() / 3; }
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline uint64_t key(int32_t x, int32_t y, int32_t z) {
|
||||
return ((uint64_t) (uint32_t) x << 40) |
|
||||
((uint64_t) (uint32_t) y << 20) |
|
||||
(uint64_t) (uint32_t) z;
|
||||
}
|
||||
|
||||
// The 4 neighbor-voxel offsets around an edge, per axis (matches
|
||||
// edge_neighbor_voxel_offset in the reference).
|
||||
static const int EDGE_OFF[3][4][3] = {
|
||||
{{0,0,0},{0,0,1},{0,1,1},{0,1,0}}, // x-axis edge
|
||||
{{0,0,0},{1,0,0},{1,0,1},{0,0,1}}, // y-axis edge
|
||||
{{0,0,0},{0,1,0},{1,1,0},{1,0,0}}, // z-axis edge
|
||||
};
|
||||
|
||||
inline void cross(const float * a, const float * b, float * o) {
|
||||
o[0] = a[1]*b[2] - a[2]*b[1];
|
||||
o[1] = a[2]*b[0] - a[0]*b[2];
|
||||
o[2] = a[0]*b[1] - a[1]*b[0];
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// feats: [n_voxels * 7], voxel-major (dec output). coords: [n_voxels * 3].
|
||||
// grid_size = input_res * decoder upscale (e.g. 32 * 16 = 512). margin 0.5.
|
||||
inline Mesh extract(const float * feats, const int32_t * coords, int n,
|
||||
int grid_size, float margin = 0.5f) {
|
||||
using namespace detail;
|
||||
Mesh m;
|
||||
if (n <= 0) return m;
|
||||
|
||||
const float vs = 1.0f / (float) grid_size; // voxel size (aabb span 1)
|
||||
const float aabb0 = -0.5f;
|
||||
|
||||
// dual vertices + hashmap
|
||||
std::vector<float> V((size_t) n * 3);
|
||||
std::unordered_map<uint64_t, int> idx;
|
||||
idx.reserve((size_t) n * 2);
|
||||
for (int v = 0; v < n; ++v) {
|
||||
const float * f = feats + (size_t) v * 7;
|
||||
for (int a = 0; a < 3; ++a) {
|
||||
const float s = 1.0f / (1.0f + std::exp(-f[a])); // sigmoid
|
||||
const float off = (1.0f + 2.0f * margin) * s - margin;
|
||||
V[(size_t) v * 3 + a] = ((float) coords[(size_t) v * 3 + a] + off) * vs + aabb0;
|
||||
}
|
||||
idx[key(coords[(size_t) v * 3], coords[(size_t) v * 3 + 1], coords[(size_t) v * 3 + 2])] = v;
|
||||
}
|
||||
m.verts = V;
|
||||
|
||||
// quads from intersected edges
|
||||
for (int v = 0; v < n; ++v) {
|
||||
const float * f = feats + (size_t) v * 7;
|
||||
const int32_t cx = coords[(size_t) v * 3];
|
||||
const int32_t cy = coords[(size_t) v * 3 + 1];
|
||||
const int32_t cz = coords[(size_t) v * 3 + 2];
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
if (f[3 + axis] <= 0.0f) continue; // not intersected on this axis
|
||||
int q[4];
|
||||
bool ok = true;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const int32_t nx = cx + EDGE_OFF[axis][i][0];
|
||||
const int32_t ny = cy + EDGE_OFF[axis][i][1];
|
||||
const int32_t nz = cz + EDGE_OFF[axis][i][2];
|
||||
auto it = idx.find(key(nx, ny, nz));
|
||||
if (it == idx.end()) { ok = false; break; }
|
||||
q[i] = it->second;
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
// Choose the quad diagonal by the decoder's learned split_weight
|
||||
// (softplus of feat[6]), exactly as the reference eval path does
|
||||
// (FlexiDualGridVaeDecoder -> flexible_dual_grid_to_mesh, train=False):
|
||||
// split 1: (0,1,2)+(0,2,3) when sw0*sw2 > sw1*sw3
|
||||
// split 2: (0,1,3)+(3,1,2) otherwise
|
||||
// (A geometric best-aligned-normals heuristic is the reference's
|
||||
// split_weight=None fallback; the shipped decoder always emits
|
||||
// feat[6], so we follow the learned choice.)
|
||||
auto sw = [&](int i) {
|
||||
const float x = feats[(size_t) q[i] * 7 + 6];
|
||||
return x > 20.0f ? x : std::log1p(std::exp(x)); // softplus
|
||||
};
|
||||
if (sw(0) * sw(2) > sw(1) * sw(3)) {
|
||||
m.tris.push_back(q[0]); m.tris.push_back(q[1]); m.tris.push_back(q[2]);
|
||||
m.tris.push_back(q[0]); m.tris.push_back(q[2]); m.tris.push_back(q[3]);
|
||||
} else {
|
||||
m.tris.push_back(q[0]); m.tris.push_back(q[1]); m.tris.push_back(q[3]);
|
||||
m.tris.push_back(q[3]); m.tris.push_back(q[1]); m.tris.push_back(q[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// Per-vertex shading normals for the dual grid's *unoriented*, heavily
|
||||
// non-manifold mesh.
|
||||
//
|
||||
// The reference mesher emits every quad with a fixed vertex order regardless of
|
||||
// which way the surface crosses the edge, so a large fraction of faces are wound
|
||||
// opposite to their neighbours. Two failure modes follow: (a) a naive
|
||||
// area-weighted normal cancels at those seams, and (b) any *sign* fix that leaves
|
||||
// stray flips is not harmless — the normal is interpolated across the triangle
|
||||
// *before* the fragment shader's abs(dot), so two adjacent vertices with opposite
|
||||
// normals make the interpolated normal cross zero mid-face → normalize() explodes
|
||||
// → speckled/blocky specular. The mesh is too non-manifold to 2-colour the
|
||||
// winding cleanly (edges shared by >2 faces frustrate it), so we don't try to.
|
||||
//
|
||||
// 1. Recover a smooth, winding-INDEPENDENT normal *direction* per vertex as the
|
||||
// dominant eigenvector of the area-weighted structure tensor Σ area·n̂n̂ᵀ
|
||||
// (immune to winding sign since n̂n̂ᵀ == (−n̂)(−n̂)ᵀ).
|
||||
// 2. Resolve the arbitrary per-vertex *sign* consistently with a parity
|
||||
// union-find over mesh edges, so edge-adjacent vertices share a hemisphere
|
||||
// and the interpolated normal stays clear of zero. Only genuinely frustrated
|
||||
// (odd-cycle / non-manifold) edges are left flipped.
|
||||
// Final shading is orientation-independent (viewer uses abs(dot)), so only local
|
||||
// smoothness matters, not a globally correct outward sign. (Winding unification
|
||||
// and sign diffusion were both tried and are worse on this mesh: 2-colouring the
|
||||
// >2-face non-manifold edges frustrates more, and Jacobi diffusion checkerboards.)
|
||||
inline std::vector<float> vertex_normals(const Mesh & m) {
|
||||
const size_t nv = m.n_verts();
|
||||
std::vector<double> A((size_t) nv * 6, 0.0); // sym structure tensor per vertex
|
||||
std::vector<float> seed((size_t) nv * 3, 0.0f); // signed area sum: a sign hint
|
||||
for (size_t t = 0; t < m.tris.size(); t += 3) {
|
||||
const int i0 = m.tris[t], i1 = m.tris[t + 1], i2 = m.tris[t + 2];
|
||||
const float * a = &m.verts[(size_t) i0 * 3];
|
||||
const float * b = &m.verts[(size_t) i1 * 3];
|
||||
const float * c = &m.verts[(size_t) i2 * 3];
|
||||
float e1[3], e2[3], fn[3];
|
||||
for (int k = 0; k < 3; ++k) { e1[k] = b[k]-a[k]; e2[k] = c[k]-a[k]; }
|
||||
detail::cross(e1, e2, fn);
|
||||
const double area = std::sqrt((double) fn[0]*fn[0] + (double) fn[1]*fn[1] + (double) fn[2]*fn[2]);
|
||||
if (area <= 1e-20) continue;
|
||||
const double inv = 1.0 / area;
|
||||
const double xx = fn[0]*fn[0]*inv, yy = fn[1]*fn[1]*inv, zz = fn[2]*fn[2]*inv;
|
||||
const double xy = fn[0]*fn[1]*inv, xz = fn[0]*fn[2]*inv, yz = fn[1]*fn[2]*inv;
|
||||
for (int i : {i0, i1, i2}) {
|
||||
double * Av = &A[(size_t) i * 6];
|
||||
Av[0]+=xx; Av[1]+=yy; Av[2]+=zz; Av[3]+=xy; Av[4]+=xz; Av[5]+=yz;
|
||||
float * sv = &seed[(size_t) i * 3];
|
||||
sv[0]+=fn[0]; sv[1]+=fn[1]; sv[2]+=fn[2];
|
||||
}
|
||||
}
|
||||
// (1) smooth, sign-ambiguous direction per vertex
|
||||
std::vector<float> dir((size_t) nv * 3, 0.0f);
|
||||
for (size_t v = 0; v < nv; ++v) {
|
||||
const double * Av = &A[v * 6];
|
||||
double x = seed[v*3], y = seed[v*3+1], z = seed[v*3+2];
|
||||
double l = std::sqrt(x*x + y*y + z*z);
|
||||
if (l < 1e-20) { x = Av[0]; y = Av[3]; z = Av[4]; l = std::sqrt(x*x+y*y+z*z); }
|
||||
if (l < 1e-20) { dir[v*3+2] = 1.0f; continue; }
|
||||
x/=l; y/=l; z/=l;
|
||||
for (int it = 0; it < 8; ++it) {
|
||||
const double nx = Av[0]*x + Av[3]*y + Av[4]*z;
|
||||
const double ny = Av[3]*x + Av[1]*y + Av[5]*z;
|
||||
const double nz = Av[4]*x + Av[5]*y + Av[2]*z;
|
||||
const double nl = std::sqrt(nx*nx + ny*ny + nz*nz);
|
||||
if (nl < 1e-20) break;
|
||||
x = nx/nl; y = ny/nl; z = nz/nl;
|
||||
}
|
||||
dir[v*3] = (float) x; dir[v*3+1] = (float) y; dir[v*3+2] = (float) z;
|
||||
}
|
||||
// (2) Resolve the arbitrary per-vertex sign *consistently* via a parity
|
||||
// union-find over mesh edges: two edge-adjacent vertices whose directions
|
||||
// are anti-aligned must end up with opposite signs (and vice versa), so
|
||||
// within each connected component neighbours share a hemisphere. Only
|
||||
// genuinely frustrated (odd-cycle / non-manifold) edges are left flipped.
|
||||
std::vector<int> ufp(nv), ufr(nv, 0);
|
||||
std::vector<uint8_t> ufb(nv, 0); // parity of a vertex relative to its parent
|
||||
for (size_t i = 0; i < nv; ++i) ufp[i] = (int) i;
|
||||
auto find = [&](int v, int & parity) {
|
||||
int p = 0;
|
||||
while (ufp[v] != v) { p ^= ufb[v]; v = ufp[v]; }
|
||||
parity = p; return v;
|
||||
};
|
||||
auto join = [&](int a, int b) {
|
||||
const float * na = &dir[(size_t) a * 3];
|
||||
const float * nb = &dir[(size_t) b * 3];
|
||||
const int rel = (na[0]*nb[0] + na[1]*nb[1] + na[2]*nb[2]) < 0.0f ? 1 : 0;
|
||||
int pa, pb, ra = find(a, pa), rb = find(b, pb);
|
||||
if (ra == rb) return;
|
||||
if (ufr[ra] < ufr[rb]) { std::swap(ra, rb); std::swap(pa, pb); }
|
||||
ufp[rb] = ra; ufb[rb] = (uint8_t) (pa ^ pb ^ rel);
|
||||
if (ufr[ra] == ufr[rb]) ufr[ra]++;
|
||||
};
|
||||
for (size_t t = 0; t < m.tris.size(); t += 3) {
|
||||
const int a = m.tris[t], b = m.tris[t+1], c = m.tris[t+2];
|
||||
join(a, b); join(b, c); join(c, a);
|
||||
}
|
||||
// (3) pick each component's global sign toward the signed-sum seed (so the
|
||||
// result is deterministic and roughly outward), then emit oriented normals
|
||||
std::unordered_map<int, double> comp_sign;
|
||||
for (size_t v = 0; v < nv; ++v) {
|
||||
int p, r = find((int) v, p);
|
||||
const double s = p ? -1.0 : 1.0;
|
||||
const float * sv = &seed[v*3];
|
||||
comp_sign[r] += s * (dir[v*3]*sv[0] + dir[v*3+1]*sv[1] + dir[v*3+2]*sv[2]);
|
||||
}
|
||||
std::vector<float> nrm((size_t) nv * 3, 0.0f);
|
||||
for (size_t v = 0; v < nv; ++v) {
|
||||
int p, r = find((int) v, p);
|
||||
double s = p ? -1.0 : 1.0;
|
||||
if (comp_sign[r] < 0.0) s = -s;
|
||||
nrm[v*3] = (float) (s * dir[v*3]);
|
||||
nrm[v*3+1] = (float) (s * dir[v*3+1]);
|
||||
nrm[v*3+2] = (float) (s * dir[v*3+2]);
|
||||
}
|
||||
return nrm;
|
||||
}
|
||||
|
||||
// Remove triangles that belong to tiny disconnected islands (the floating
|
||||
// specks that read as "blemishes"), keeping the vertex array and its indexing
|
||||
// intact so a parallel per-vertex attribute array (e.g. baked PBR) stays
|
||||
// aligned. Components are face groups connected through shared vertices; a
|
||||
// component is dropped when its face count is below min_frac of the total.
|
||||
inline void drop_small_components(Mesh & m, float min_frac = 0.0005f) {
|
||||
const size_t nt = m.n_tris();
|
||||
if (nt == 0) return;
|
||||
const size_t nv = m.n_verts();
|
||||
std::vector<int> p(nv);
|
||||
for (size_t i = 0; i < nv; ++i) p[i] = (int) i;
|
||||
auto find = [&](int x) { while (p[x] != x) { p[x] = p[p[x]]; x = p[x]; } return x; };
|
||||
auto uni = [&](int a, int b) { a = find(a); b = find(b); if (a != b) p[a] = b; };
|
||||
for (size_t t = 0; t < m.tris.size(); t += 3) {
|
||||
uni(m.tris[t], m.tris[t+1]); uni(m.tris[t+1], m.tris[t+2]);
|
||||
}
|
||||
std::unordered_map<int, int> faces;
|
||||
for (size_t t = 0; t < m.tris.size(); t += 3) faces[find(m.tris[t])]++;
|
||||
const int min_faces = std::max(1, (int) (min_frac * (double) nt));
|
||||
std::vector<int> keep;
|
||||
keep.reserve(m.tris.size());
|
||||
for (size_t t = 0; t < m.tris.size(); t += 3) {
|
||||
if (faces[find(m.tris[t])] >= min_faces) {
|
||||
keep.push_back(m.tris[t]); keep.push_back(m.tris[t+1]); keep.push_back(m.tris[t+2]);
|
||||
}
|
||||
}
|
||||
m.tris.swap(keep);
|
||||
}
|
||||
|
||||
// Fill the small holes extract() leaves where a dual-grid quad was skipped (a
|
||||
// neighbour voxel was missing). A boundary edge is one used by exactly one
|
||||
// triangle; boundary edges chain into loops around each hole. Each loop up to
|
||||
// `max_loop` vertices is fan-triangulated from its first vertex. Only triangles
|
||||
// are added — no new vertices — so a parallel per-vertex attribute array (baked
|
||||
// PBR) stays aligned. Large loops (genuine openings) are left unfilled.
|
||||
inline void fill_holes(Mesh & m, int max_loop = 64, int max_passes = 4) {
|
||||
if (m.n_tris() == 0) return;
|
||||
auto key = [](int a, int b) {
|
||||
const uint32_t lo = a < b ? a : b, hi = a < b ? b : a;
|
||||
return ((uint64_t) lo << 32) | hi;
|
||||
};
|
||||
// Iterate: the greedy loop walk misses some loops at non-manifold junctions,
|
||||
// and each fill can expose newly closeable loops, so repeat until a pass
|
||||
// adds nothing (or the pass cap is hit).
|
||||
for (int pass = 0; pass < max_passes; ++pass) {
|
||||
const size_t before = m.tris.size();
|
||||
// run-length count of undirected edges via sort (lighter than a hashmap)
|
||||
std::vector<uint64_t> ek;
|
||||
ek.reserve(m.tris.size());
|
||||
for (size_t t = 0; t < m.tris.size(); t += 3) {
|
||||
ek.push_back(key(m.tris[t], m.tris[t+1]));
|
||||
ek.push_back(key(m.tris[t+1], m.tris[t+2]));
|
||||
ek.push_back(key(m.tris[t+2], m.tris[t]));
|
||||
}
|
||||
std::sort(ek.begin(), ek.end());
|
||||
std::unordered_map<int, std::vector<int>> adj; // boundary-vertex adjacency
|
||||
for (size_t i = 0; i < ek.size(); ) {
|
||||
size_t j = i + 1;
|
||||
while (j < ek.size() && ek[j] == ek[i]) ++j;
|
||||
if (j - i == 1) { // used by exactly one triangle -> boundary edge
|
||||
const int a = (int) (ek[i] >> 32), b = (int) (ek[i] & 0xffffffffu);
|
||||
adj[a].push_back(b); adj[b].push_back(a);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
if (adj.empty()) break;
|
||||
|
||||
std::unordered_set<uint64_t> used; // consumed boundary edges
|
||||
for (const auto & kv : adj) {
|
||||
const int start = kv.first;
|
||||
for (const int nb0 : kv.second) {
|
||||
if (used.count(key(start, nb0))) continue;
|
||||
std::vector<int> loop{start};
|
||||
int prev = start, cur = nb0;
|
||||
used.insert(key(prev, cur));
|
||||
bool closed = false;
|
||||
while ((int) loop.size() <= max_loop) {
|
||||
loop.push_back(cur);
|
||||
if (cur == start) { closed = true; break; }
|
||||
int next = -1;
|
||||
auto it = adj.find(cur);
|
||||
if (it != adj.end())
|
||||
for (const int c : it->second)
|
||||
if (c != prev && !used.count(key(cur, c))) { next = c; break; }
|
||||
if (next < 0) break; // open chain / dead end
|
||||
used.insert(key(cur, next));
|
||||
prev = cur; cur = next;
|
||||
}
|
||||
if (!closed) continue;
|
||||
loop.pop_back(); // drop the repeated start vertex
|
||||
const int k = (int) loop.size();
|
||||
if (k < 3 || k > max_loop) continue;
|
||||
for (int t = 1; t < k - 1; ++t) { // fan-triangulate from loop[0]
|
||||
m.tris.push_back(loop[0]);
|
||||
m.tris.push_back(loop[t]);
|
||||
m.tris.push_back(loop[t + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.tris.size() == before) break; // converged
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace fdg
|
||||
@@ -0,0 +1,243 @@
|
||||
// marching_cubes.h — single-file, self-contained isosurface extraction.
|
||||
//
|
||||
// Extracts a triangle mesh for the level set {f = iso} of a scalar field
|
||||
// sampled on a regular grid, and writes it as a Wavefront OBJ.
|
||||
//
|
||||
// It uses the TETRAHEDRAL variant of marching cubes (a.k.a. marching
|
||||
// tetrahedra on the Freudenthal/Kuhn subdivision): each grid cube is split
|
||||
// into 6 tetrahedra that all share the (0,0,0)-(1,1,1) main diagonal, and each
|
||||
// tetrahedron is contoured with a tiny 16-case table. Compared to classic
|
||||
// marching cubes this avoids the error-prone 256-row triangle table entirely,
|
||||
// and — because every cube uses the same diagonal — the subdivision tiles space
|
||||
// consistently, so the output is a watertight 2-manifold (each interior edge is
|
||||
// shared by exactly two triangles). Vertices are de-duplicated by the global
|
||||
// grid edge they sit on, so the mesh is properly indexed.
|
||||
//
|
||||
// Header-only and dependency-free (just <vector>/<cmath>/<unordered_map>).
|
||||
//
|
||||
// mc::Mesh m = mc::extract(field, nx, ny, nz, iso);
|
||||
// mc::write_obj(m, "out.obj");
|
||||
//
|
||||
// `field` is indexed x + y*nx + z*nx*ny (x fastest). Vertex positions are in
|
||||
// grid-index units (a voxel is one unit). The field is treated as `pad` (a
|
||||
// large negative value, i.e. "outside") beyond the grid, so surfaces that reach
|
||||
// the boundary are closed off.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace mc {
|
||||
|
||||
struct Mesh {
|
||||
std::vector<float> verts; // 3 floats per vertex (x,y,z)
|
||||
std::vector<float> normals; // 3 floats per vertex (unit, oriented outward)
|
||||
std::vector<int> tris; // 3 vertex indices per triangle
|
||||
size_t n_verts() const { return verts.size() / 3; }
|
||||
size_t n_tris() const { return tris.size() / 3; }
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
|
||||
// Cube corner offsets, Bourke/Lorensen order.
|
||||
static const int CORNER[8][3] = {
|
||||
{0,0,0},{1,0,0},{1,1,0},{0,1,0},{0,0,1},{1,0,1},{1,1,1},{0,1,1}
|
||||
};
|
||||
|
||||
// The 6 tetrahedra of the Freudenthal subdivision, all sharing the 0-6 diagonal.
|
||||
static const int TETRA[6][4] = {
|
||||
{0,1,2,6},{0,2,3,6},{0,3,7,6},{0,7,4,6},{0,4,5,6},{0,5,1,6}
|
||||
};
|
||||
|
||||
// A tetrahedron's 6 edges, as (local-corner, local-corner) pairs.
|
||||
static const int TET_EDGE[6][2] = {
|
||||
{0,1},{1,2},{2,0},{0,3},{1,3},{2,3}
|
||||
};
|
||||
|
||||
// Per-case triangle table for one tetrahedron. Index = bitmask of which of the
|
||||
// 4 local corners are "inside" (value > iso); values are TET_EDGE indices,
|
||||
// terminated by -1. Derived by hand (see marching_cubes.h history) and checked
|
||||
// at runtime by extract()'s self-consistency asserts on small fields.
|
||||
static const int TET_TRI[16][7] = {
|
||||
{-1,-1,-1,-1,-1,-1,-1}, // 0000
|
||||
{ 0, 2, 3,-1,-1,-1,-1}, // 0001 {0}
|
||||
{ 0, 1, 4,-1,-1,-1,-1}, // 0010 {1}
|
||||
{ 2, 1, 4, 2, 4, 3,-1}, // 0011 {0,1}
|
||||
{ 2, 1, 5,-1,-1,-1,-1}, // 0100 {2}
|
||||
{ 0, 1, 5, 0, 5, 3,-1}, // 0101 {0,2}
|
||||
{ 0, 2, 5, 0, 5, 4,-1}, // 0110 {1,2}
|
||||
{ 3, 4, 5,-1,-1,-1,-1}, // 0111 {0,1,2} (outside 3)
|
||||
{ 3, 4, 5,-1,-1,-1,-1}, // 1000 {3}
|
||||
{ 0, 4, 5, 0, 5, 2,-1}, // 1001 {0,3}
|
||||
{ 0, 3, 5, 0, 5, 1,-1}, // 1010 {1,3}
|
||||
{ 2, 1, 5,-1,-1,-1,-1}, // 1011 {0,1,3} (outside 2)
|
||||
{ 2, 3, 4, 2, 4, 1,-1}, // 1100 {2,3}
|
||||
{ 0, 1, 4,-1,-1,-1,-1}, // 1101 {0,2,3} (outside 1)
|
||||
{ 0, 2, 3,-1,-1,-1,-1}, // 1110 {1,2,3} (outside 0)
|
||||
{-1,-1,-1,-1,-1,-1,-1}, // 1111
|
||||
};
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline Mesh extract(const float * field, int nx, int ny, int nz, float iso,
|
||||
float pad = -1e30f) {
|
||||
using namespace detail;
|
||||
Mesh mesh;
|
||||
|
||||
// Tie-breaking jitter (Simulation-of-Simplicity style): a corner whose value
|
||||
// is *exactly* iso makes the surface pass through that single point, which is
|
||||
// shared by every incident tetrahedron — a pinch that no winding can make
|
||||
// coherent. A tiny deterministic per-corner perturbation (consistent across
|
||||
// cubes, so shared corners still agree -> mesh stays watertight) pushes every
|
||||
// corner decisively on/off the surface. Scaled to the field so vertex motion
|
||||
// is sub-micro-voxel; continuous fields (e.g. decoder logits) are unaffected.
|
||||
double fmax = 0.0;
|
||||
for (size_t i = 0; i < (size_t) nx * ny * nz; ++i) {
|
||||
double a = std::fabs((double) field[i]); if (a > fmax) fmax = a;
|
||||
}
|
||||
const float eps = (float) (1e-6 * fmax) + 1e-20f;
|
||||
auto jitter = [](int x, int y, int z) -> float {
|
||||
uint32_t h = (uint32_t)(x * 73856093) ^ (uint32_t)(y * 19349663) ^ (uint32_t)(z * 83492791);
|
||||
h ^= h >> 13; h *= 0x5bd1e995u; h ^= h >> 15;
|
||||
return (float)(h & 0xffff) / 65535.0f - 0.5f; // [-0.5, 0.5)
|
||||
};
|
||||
auto at = [&](int x, int y, int z) -> float {
|
||||
if (x < 0 || y < 0 || z < 0 || x >= nx || y >= ny || z >= nz) return pad;
|
||||
return field[(size_t) x + (size_t) y * nx + (size_t) z * nx * ny] + eps * jitter(x, y, z);
|
||||
};
|
||||
// Central-difference gradient (for outward-oriented vertex normals).
|
||||
auto grad = [&](int x, int y, int z, float g[3]) {
|
||||
g[0] = at(x + 1, y, z) - at(x - 1, y, z);
|
||||
g[1] = at(x, y + 1, z) - at(x, y - 1, z);
|
||||
g[2] = at(x, y, z + 1) - at(x, y, z - 1);
|
||||
};
|
||||
|
||||
std::unordered_map<int64_t, int> vmap; // quantized-position key -> vertex index
|
||||
|
||||
// Interpolate (and cache) the vertex on the grid edge cA->cB. Vertices are
|
||||
// de-duplicated by QUANTIZED POSITION, not by edge: against the large pad,
|
||||
// boundary-cap vertices interpolate to t~0 and collapse exactly onto grid
|
||||
// corners, so several distinct edges land on the same point. Position keying
|
||||
// welds those into one vertex (killing the degenerate zero-area cap triangles
|
||||
// and keeping the boundary watertight), while shared edges across cubes —
|
||||
// which already yield identical positions — still merge as before.
|
||||
const double QS = 1024.0; // sub-milli-voxel quantization
|
||||
auto edge_vertex = [&](const int ca[3], const int cb[3], float va, float vb) -> int {
|
||||
float t = (vb != va) ? (iso - va) / (vb - va) : 0.5f;
|
||||
if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
|
||||
float p[3] = { ca[0] + t * (cb[0] - ca[0]),
|
||||
ca[1] + t * (cb[1] - ca[1]),
|
||||
ca[2] + t * (cb[2] - ca[2]) };
|
||||
int64_t qx = (int64_t) std::llround(p[0] * QS) + 0x200000;
|
||||
int64_t qy = (int64_t) std::llround(p[1] * QS) + 0x200000;
|
||||
int64_t qz = (int64_t) std::llround(p[2] * QS) + 0x200000;
|
||||
int64_t key = (qx * 0x400000LL + qy) * 0x400000LL + qz;
|
||||
auto it = vmap.find(key);
|
||||
if (it != vmap.end()) return it->second;
|
||||
|
||||
float ga[3], gb[3];
|
||||
grad(ca[0], ca[1], ca[2], ga);
|
||||
grad(cb[0], cb[1], cb[2], gb);
|
||||
float n[3];
|
||||
for (int d = 0; d < 3; ++d) n[d] = -(ga[d] + t * (gb[d] - ga[d])); // outward = -grad
|
||||
// Normalize in double: against the large negative pad the gradient can
|
||||
// reach ~1e30, and 1e30^2 overflows float32 to inf (-> bogus normal).
|
||||
double len = std::sqrt((double)n[0]*n[0] + (double)n[1]*n[1] + (double)n[2]*n[2]);
|
||||
if (len > 1e-12) { n[0]=(float)(n[0]/len); n[1]=(float)(n[1]/len); n[2]=(float)(n[2]/len); }
|
||||
else { n[0]=0; n[1]=0; n[2]=1; }
|
||||
|
||||
int idx = (int) mesh.n_verts();
|
||||
mesh.verts.insert(mesh.verts.end(), { p[0], p[1], p[2] });
|
||||
mesh.normals.insert(mesh.normals.end(), { n[0], n[1], n[2] });
|
||||
vmap.emplace(key, idx);
|
||||
return idx;
|
||||
};
|
||||
|
||||
// Iterate cube origins over [-1 .. n-1] so boundary cubes (against the pad)
|
||||
// close the surface.
|
||||
for (int z = -1; z < nz; ++z)
|
||||
for (int y = -1; y < ny; ++y)
|
||||
for (int x = -1; x < nx; ++x) {
|
||||
float cval[8];
|
||||
int ccoord[8][3];
|
||||
for (int c = 0; c < 8; ++c) {
|
||||
ccoord[c][0] = x + CORNER[c][0];
|
||||
ccoord[c][1] = y + CORNER[c][1];
|
||||
ccoord[c][2] = z + CORNER[c][2];
|
||||
cval[c] = at(ccoord[c][0], ccoord[c][1], ccoord[c][2]);
|
||||
}
|
||||
for (int t = 0; t < 6; ++t) {
|
||||
const int * tc = TETRA[t];
|
||||
int code = 0;
|
||||
for (int i = 0; i < 4; ++i) if (cval[tc[i]] > iso) code |= (1 << i);
|
||||
const int * tri = TET_TRI[code];
|
||||
if (tri[0] < 0) continue;
|
||||
|
||||
// Gather this tetra's triangles (1 or 2), then decide their winding
|
||||
// ONCE, together: flip so the summed geometric normal (snorm) agrees
|
||||
// with the summed outward vertex normal (gsum = sum of -grad over the
|
||||
// triangle vertices). gsum is PARALLEL to the surface normal, so the
|
||||
// dot is never near zero — unlike a centroid-difference proxy, which
|
||||
// can be in-plane at boundary/crease tetra and pick a random sign.
|
||||
// The surface inside a tetra is planar, so a quad's two triangles are
|
||||
// coplanar and flip as a unit (a per-triangle test would mis-flip a
|
||||
// sliver triangle, whose own geometric normal is ~0).
|
||||
int tribuf[2][3]; int ntri = 0;
|
||||
double snorm[3] = {0, 0, 0}, gsum[3] = {0, 0, 0};
|
||||
for (int e = 0; tri[e] >= 0; e += 3) {
|
||||
int vi[3];
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
const int * ed = TET_EDGE[tri[e + k]];
|
||||
int la = tc[ed[0]], lb = tc[ed[1]];
|
||||
vi[k] = edge_vertex(ccoord[la], ccoord[lb], cval[la], cval[lb]);
|
||||
}
|
||||
if (vi[0] == vi[1] || vi[1] == vi[2] || vi[0] == vi[2]) continue; // degenerate
|
||||
const float * P0 = &mesh.verts[3*vi[0]];
|
||||
const float * P1 = &mesh.verts[3*vi[1]];
|
||||
const float * P2 = &mesh.verts[3*vi[2]];
|
||||
float u[3] = { P1[0]-P0[0], P1[1]-P0[1], P1[2]-P0[2] };
|
||||
float v[3] = { P2[0]-P0[0], P2[1]-P0[1], P2[2]-P0[2] };
|
||||
snorm[0] += u[1]*v[2]-u[2]*v[1];
|
||||
snorm[1] += u[2]*v[0]-u[0]*v[2];
|
||||
snorm[2] += u[0]*v[1]-u[1]*v[0];
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
gsum[0] += mesh.normals[3*vi[k]];
|
||||
gsum[1] += mesh.normals[3*vi[k]+1];
|
||||
gsum[2] += mesh.normals[3*vi[k]+2];
|
||||
}
|
||||
tribuf[ntri][0] = vi[0]; tribuf[ntri][1] = vi[1]; tribuf[ntri][2] = vi[2];
|
||||
++ntri;
|
||||
}
|
||||
bool flip = (snorm[0]*gsum[0] + snorm[1]*gsum[1] + snorm[2]*gsum[2]) < 0.0;
|
||||
for (int a = 0; a < ntri; ++a) {
|
||||
int v0 = tribuf[a][0], v1 = tribuf[a][1], v2 = tribuf[a][2];
|
||||
if (flip) { int tmp = v1; v1 = v2; v2 = tmp; }
|
||||
mesh.tris.insert(mesh.tris.end(), { v0, v1, v2 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return mesh;
|
||||
}
|
||||
|
||||
inline bool write_obj(const Mesh & m, const char * path) {
|
||||
FILE * f = std::fopen(path, "wb");
|
||||
if (!f) return false;
|
||||
std::fprintf(f, "# trellis2.cpp isosurface (%zu verts, %zu tris)\n",
|
||||
m.n_verts(), m.n_tris());
|
||||
for (size_t i = 0; i < m.n_verts(); ++i)
|
||||
std::fprintf(f, "v %.6f %.6f %.6f\n", m.verts[3*i], m.verts[3*i+1], m.verts[3*i+2]);
|
||||
for (size_t i = 0; i < m.n_verts(); ++i)
|
||||
std::fprintf(f, "vn %.6f %.6f %.6f\n", m.normals[3*i], m.normals[3*i+1], m.normals[3*i+2]);
|
||||
for (size_t i = 0; i < m.n_tris(); ++i) {
|
||||
int a = m.tris[3*i] + 1, b = m.tris[3*i+1] + 1, c = m.tris[3*i+2] + 1;
|
||||
std::fprintf(f, "f %d//%d %d//%d %d//%d\n", a, a, b, b, c, c);
|
||||
}
|
||||
std::fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mc
|
||||
@@ -0,0 +1,130 @@
|
||||
// mesh2glb — export a demo mesh (T2MESH01/T2MESH02/T2MESH03 wire format) to a
|
||||
// portable vertex-coloured GLB. Exercises the CUDA-free component cleanup and
|
||||
// glTF path offline, with no models or GPU. T2GLB_XATLAS opts into image baking.
|
||||
//
|
||||
// mesh2glb in.bin out.glb [texture_size] [--print [alpha_pct offset_pct]]
|
||||
//
|
||||
// The wire format is what the demo server emits at /api/mesh/{id}:
|
||||
// magic[8] u32 nv u32 nt f32[3nv] verts f32[3nv] normals
|
||||
// [T2MESH02: f32[5nv] legacy pbr]
|
||||
// [T2MESH03: f32[6nv] pbr incl. alpha] i32[3nt] tris (little-endian)
|
||||
|
||||
#include "mesh_export.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
template <class T>
|
||||
static bool rd(FILE * f, std::vector<T> & v, size_t n) {
|
||||
v.resize(n);
|
||||
return n == 0 || std::fread(v.data(), sizeof(T), n, f) == n;
|
||||
}
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 3) {
|
||||
std::fprintf(stderr,
|
||||
"usage: %s in.bin out.glb [texture_size] [--print [alpha_pct offset_pct]]\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
t2glb::MeshExportOptions opt;
|
||||
opt.components = t2glb::ComponentFilter::KeepAll;
|
||||
bool print_wrap = false;
|
||||
float alpha_ratio = 0.01f, offset_ratio = 0.01f / 30.0f;
|
||||
for (int i = 3; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--print") == 0) {
|
||||
print_wrap = true;
|
||||
if (i + 1 < argc && argv[i+1][0] != '-') alpha_ratio = std::atof(argv[++i]) / 100.0f;
|
||||
if (i + 1 < argc && argv[i+1][0] != '-') offset_ratio = std::atof(argv[++i]) / 100.0f;
|
||||
} else {
|
||||
opt.texture_size = std::atoi(argv[i]);
|
||||
}
|
||||
}
|
||||
if (print_wrap && !t2glb::print_remesh_available()) {
|
||||
std::fprintf(stderr, "print remeshing unavailable: rebuild with CGAL 5.5 or newer\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
FILE * f = std::fopen(argv[1], "rb");
|
||||
if (!f) { std::fprintf(stderr, "open %s failed\n", argv[1]); return 1; }
|
||||
char magic[9] = {0};
|
||||
uint32_t nv = 0, nt = 0;
|
||||
if (std::fread(magic, 1, 8, f) != 8 || std::fread(&nv, 4, 1, f) != 1 || std::fread(&nt, 4, 1, f) != 1) {
|
||||
std::fprintf(stderr, "bad header\n"); return 1;
|
||||
}
|
||||
const bool legacy = std::memcmp(magic, "T2MESH02", 8) == 0;
|
||||
const bool textured = legacy || std::memcmp(magic, "T2MESH03", 8) == 0;
|
||||
if (!textured && std::memcmp(magic, "T2MESH01", 8) != 0) {
|
||||
std::fprintf(stderr, "unknown magic\n"); return 1;
|
||||
}
|
||||
std::vector<float> verts, normals, pbr; std::vector<int32_t> tris;
|
||||
bool ok = rd(f, verts, (size_t) nv * 3) && rd(f, normals, (size_t) nv * 3);
|
||||
if (ok && textured) {
|
||||
if (legacy) {
|
||||
std::vector<float> old;
|
||||
ok = rd(f, old, (size_t) nv * 5);
|
||||
if (ok) {
|
||||
pbr.resize((size_t) nv * 6);
|
||||
for (uint32_t i = 0; i < nv; ++i) {
|
||||
std::memcpy(pbr.data() + (size_t) i * 6,
|
||||
old.data() + (size_t) i * 5, 5 * sizeof(float));
|
||||
pbr[(size_t) i * 6 + 5] = 1.0f;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ok = rd(f, pbr, (size_t) nv * 6);
|
||||
}
|
||||
}
|
||||
ok = ok && rd(f, tris, (size_t) nt * 3);
|
||||
std::fclose(f);
|
||||
if (!ok) { std::fprintf(stderr, "truncated mesh\n"); return 1; }
|
||||
|
||||
std::fprintf(stderr, "in: %s %u verts %u tris %s\n", magic, nv, nt,
|
||||
textured ? "textured" : "geometry-only");
|
||||
std::fprintf(stderr, "export: %s ...\n",
|
||||
print_wrap ? "constructing watertight CGAL Alpha Wrap" : "preserving input topology");
|
||||
|
||||
std::vector<uint8_t> glb; std::string err;
|
||||
t2glb::PreparedMesh wrapped;
|
||||
const float * export_verts = verts.data();
|
||||
const int32_t * export_tris = tris.data();
|
||||
const float * export_pbr = textured ? pbr.data() : nullptr;
|
||||
int export_nv = (int) nv, export_nt = (int) nt;
|
||||
if (print_wrap) {
|
||||
if (!t2glb::prepare_print_mesh(verts.data(), (int) nv, tris.data(), (int) nt,
|
||||
export_pbr, opt, alpha_ratio, offset_ratio,
|
||||
wrapped, err)) {
|
||||
std::fprintf(stderr, "prepare_print_mesh: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
export_verts = wrapped.verts.data(); export_nv = (int) wrapped.verts.size() / 3;
|
||||
export_tris = wrapped.tris.data(); export_nt = (int) wrapped.tris.size() / 3;
|
||||
export_pbr = nullptr;
|
||||
opt.components = t2glb::ComponentFilter::KeepAll;
|
||||
std::fprintf(stderr, "wrap: %d verts %d tris %s\n", export_nv, export_nt,
|
||||
textured ? "rebaking source PBR atlas" : "geometry-only");
|
||||
}
|
||||
const bool projected = print_wrap && textured;
|
||||
const bool baked = projected
|
||||
? t2glb::mesh_to_projected_glb(
|
||||
export_verts, export_nv, export_tris, export_nt,
|
||||
verts.data(), (int) nv, tris.data(), (int) nt, pbr.data(),
|
||||
opt, glb, err)
|
||||
: t2glb::mesh_to_glb(export_verts, export_nv, export_tris, export_nt,
|
||||
export_pbr, opt, glb, err);
|
||||
if (!baked) {
|
||||
std::fprintf(stderr, "%s: %s\n",
|
||||
projected ? "mesh_to_projected_glb" : "mesh_to_glb", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
FILE * o = std::fopen(argv[2], "wb");
|
||||
if (!o) { std::fprintf(stderr, "open %s failed\n", argv[2]); return 1; }
|
||||
std::fwrite(glb.data(), 1, glb.size(), o);
|
||||
std::fclose(o);
|
||||
std::fprintf(stderr, "wrote %s (%.2f MB)\n", argv[2], glb.size() / 1048576.0);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// ss_decode — run the stage-1 SS decoder: a sparse-structure latent z_s
|
||||
// (.latent from ss_sample) + the SS decoder GGUF -> an occupancy logit grid.
|
||||
// The coarse voxel scaffold is logit > 0.
|
||||
//
|
||||
// usage: ss_decode <ss_dec.gguf> <z_s.latent> [out.occ]
|
||||
//
|
||||
// Writes the occupancy logit grid (channel-major [out_channels * R_out^3]
|
||||
// float32) to out.occ if given. Also prints occupancy stats and, if all output
|
||||
// channels are 1, the count of occupied voxels.
|
||||
|
||||
#include "trellis2.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 3) {
|
||||
std::fprintf(stderr, "usage: %s <ss_dec.gguf> <z_s.latent> [out.occ]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const std::string gguf_path = argv[1];
|
||||
const std::string lat_path = argv[2];
|
||||
const std::string out_path = argc > 3 ? argv[3] : "";
|
||||
|
||||
std::printf("trellis2.cpp %s\n", trellis2_version());
|
||||
|
||||
std::string err;
|
||||
trellis2_ss_dec_model * m = trellis2_ss_dec_load(gguf_path, true, &err);
|
||||
if (!m) { std::fprintf(stderr, "model load error: %s\n", err.c_str()); return 1; }
|
||||
std::printf("backend: %s\n", trellis2_ss_dec_backend_name(m));
|
||||
|
||||
const trellis2_ss_dec_hparams hp = trellis2_ss_dec_hparams_of(m); // copy (used after free)
|
||||
const int Rin = hp.res_in();
|
||||
const int Rout = hp.res_out();
|
||||
const size_t n_in = (size_t) hp.latent_channels * Rin * Rin * Rin;
|
||||
const size_t n_out = (size_t) hp.out_channels * Rout * Rout * Rout;
|
||||
|
||||
// load latent
|
||||
std::ifstream f(lat_path, std::ios::binary);
|
||||
if (!f) { std::fprintf(stderr, "cannot open latent %s\n", lat_path.c_str()); trellis2_ss_dec_free(m); return 1; }
|
||||
std::vector<float> latent(n_in);
|
||||
f.read(reinterpret_cast<char *>(latent.data()), (std::streamsize) (n_in * sizeof(float)));
|
||||
if ((size_t) f.gcount() != n_in * sizeof(float)) {
|
||||
std::fprintf(stderr, "latent size mismatch: got %zu floats, want %zu\n",
|
||||
(size_t) f.gcount() / sizeof(float), n_in);
|
||||
trellis2_ss_dec_free(m);
|
||||
return 1;
|
||||
}
|
||||
std::printf("latent : [%d,%d,%d,%d]\n", hp.latent_channels, Rin, Rin, Rin);
|
||||
|
||||
std::vector<float> occ(n_out, 0.0f);
|
||||
if (!trellis2_ss_dec_decode(m, latent.data(), occ.data(), &err)) {
|
||||
std::fprintf(stderr, "decode error: %s\n", err.c_str());
|
||||
trellis2_ss_dec_free(m);
|
||||
return 1;
|
||||
}
|
||||
trellis2_ss_dec_free(m);
|
||||
|
||||
double mn = 1e30, mx = -1e30, sum = 0.0;
|
||||
size_t occupied = 0;
|
||||
for (float v : occ) { mn = v < mn ? v : mn; mx = v > mx ? v : mx; sum += v; if (v > 0.0f) ++occupied; }
|
||||
std::printf("logits : [%d,%d,%d,%d] min=%.4f max=%.4f mean=%.5f occupied(>0)=%zu/%zu (%.2f%%)\n",
|
||||
hp.out_channels, Rout, Rout, Rout, mn, mx, sum / (double) n_out,
|
||||
occupied, n_out, 100.0 * (double) occupied / (double) n_out);
|
||||
|
||||
if (!out_path.empty()) {
|
||||
std::ofstream o(out_path, std::ios::binary);
|
||||
o.write(reinterpret_cast<const char *>(occ.data()), (std::streamsize) (n_out * sizeof(float)));
|
||||
std::printf("wrote %s (%zu floats)\n", out_path.c_str(), n_out);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// ss_flow_info — load a converted SS-flow DiT GGUF and print its hyperparameters
|
||||
// and tensor inventory. Validates that convert_ss_flow_to_gguf.py produced a file
|
||||
// ggml can read, and that every weight is reachable by name.
|
||||
//
|
||||
// usage: ss_flow_info <path-to.gguf> [--load]
|
||||
//
|
||||
// By default only metadata is parsed (fast). Pass --load to also read all weight
|
||||
// data into host memory (~2.6 GB for the f16 checkpoint).
|
||||
|
||||
#include "trellis2.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 2) {
|
||||
std::fprintf(stderr, "usage: %s <path-to.gguf> [--load]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const std::string path = argv[1];
|
||||
const bool load_tensors = (argc > 2 && std::strcmp(argv[2], "--load") == 0);
|
||||
|
||||
std::printf("trellis2.cpp %s\n", trellis2_version());
|
||||
|
||||
std::string err;
|
||||
trellis2_ss_flow_model * m = trellis2_ss_flow_load(path, load_tensors, &err);
|
||||
if (!m) {
|
||||
std::fprintf(stderr, "error: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
const trellis2_ss_flow_hparams & hp = trellis2_ss_flow_hparams_of(m);
|
||||
std::printf("file : %s (%s)\n", path.c_str(),
|
||||
load_tensors ? "weights loaded" : "metadata only");
|
||||
if (load_tensors) {
|
||||
std::printf("backend : %s\n", trellis2_ss_flow_backend_name(m));
|
||||
}
|
||||
std::printf("hyperparameters:\n");
|
||||
std::printf(" resolution : %d (grid %d^3 = %d tokens)\n",
|
||||
hp.resolution, hp.resolution, hp.resolution * hp.resolution * hp.resolution);
|
||||
std::printf(" in/out channels : %d / %d\n", hp.in_channels, hp.out_channels);
|
||||
std::printf(" model_channels : %d\n", hp.model_channels);
|
||||
std::printf(" cond_channels : %d\n", hp.cond_channels);
|
||||
std::printf(" num_blocks : %d\n", hp.num_blocks);
|
||||
std::printf(" num_heads : %d (head_dim %d)\n", hp.num_heads, hp.head_dim());
|
||||
std::printf(" mlp_ratio : %.4f\n", hp.mlp_ratio);
|
||||
std::printf(" pe_mode : %s (freq %.1f..%.1f)\n",
|
||||
hp.pe_mode, hp.rope_freq_min, hp.rope_freq_base);
|
||||
std::printf(" share_mod : %s\n", hp.share_mod ? "true" : "false");
|
||||
std::printf(" qk_rms_norm : self=%s cross=%s\n",
|
||||
hp.qk_rms_norm ? "true" : "false",
|
||||
hp.qk_rms_norm_cross ? "true" : "false");
|
||||
std::printf(" file_type : %d (0=f32,1=f16,2=bf16)\n", hp.file_type);
|
||||
|
||||
const int n = trellis2_ss_flow_n_tensors(m);
|
||||
std::printf("tensors : %d\n", n);
|
||||
|
||||
// Print the global (non-block) tensors in full, then summarize block 0.
|
||||
std::printf(" -- global + block 0 (sample) --\n");
|
||||
size_t total_bytes = 0;
|
||||
int shown = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
trellis2_tensor_info ti;
|
||||
if (!trellis2_ss_flow_get_tensor_info(m, i, ti)) continue;
|
||||
total_bytes += ti.n_bytes;
|
||||
const bool is_global = ti.name.rfind("blocks.", 0) != 0;
|
||||
const bool is_block0 = ti.name.rfind("blocks.0.", 0) == 0;
|
||||
if ((is_global || is_block0) && shown < 40) {
|
||||
std::printf(" %-40s %-4s [", ti.name.c_str(), ti.type_name.c_str());
|
||||
for (int d = ti.n_dims - 1; d >= 0; --d) {
|
||||
std::printf("%lld%s", (long long) ti.ne[d], d ? ", " : "");
|
||||
}
|
||||
std::printf("]\n");
|
||||
++shown;
|
||||
}
|
||||
}
|
||||
std::printf("total weight bytes: %.2f MB\n", total_bytes / (1024.0 * 1024.0));
|
||||
|
||||
// Spot-check a couple of expected names.
|
||||
const char * probes[] = {
|
||||
"input_layer.weight", "adaLN_modulation.1.weight",
|
||||
"blocks.0.self_attn.to_qkv.weight", "blocks.29.cross_attn.to_kv.weight",
|
||||
"out_layer.weight",
|
||||
};
|
||||
std::printf("name probes:\n");
|
||||
for (const char * p : probes) {
|
||||
std::printf(" %-40s %s\n", p,
|
||||
trellis2_ss_flow_has_tensor(m, p) ? "present" : "MISSING");
|
||||
}
|
||||
|
||||
trellis2_ss_flow_free(m);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// ss_mesh — run the stage-1 SS decoder and export the occupancy isosurface as
|
||||
// a Wavefront OBJ via (tetrahedral) marching cubes.
|
||||
//
|
||||
// usage: ss_mesh <ss_dec.gguf> <z_s.latent> [out.obj] [--iso V] [--normalize]
|
||||
//
|
||||
// Decodes z_s -> a 64^3 occupancy logit grid, extracts the {logit = iso} surface
|
||||
// (default iso 0, i.e. the occupancy boundary), and writes it as out.obj
|
||||
// (default ss_mesh.obj). With --normalize, vertices are mapped from grid-index
|
||||
// units into the centered unit cube [-0.5, 0.5]^3.
|
||||
|
||||
#include "trellis2.h"
|
||||
#include "marching_cubes.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 3) {
|
||||
std::fprintf(stderr, "usage: %s <ss_dec.gguf> <z_s.latent> [out.obj] [--iso V] [--normalize]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const std::string gguf_path = argv[1];
|
||||
const std::string lat_path = argv[2];
|
||||
std::string out_path = "ss_mesh.obj";
|
||||
float iso = 0.0f;
|
||||
bool normalize = false;
|
||||
bool out_set = false;
|
||||
for (int i = 3; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--iso") == 0 && i + 1 < argc) iso = (float) std::atof(argv[++i]);
|
||||
else if (std::strcmp(argv[i], "--normalize") == 0) normalize = true;
|
||||
else if (!out_set) { out_path = argv[i]; out_set = true; }
|
||||
}
|
||||
|
||||
std::printf("trellis2.cpp %s\n", trellis2_version());
|
||||
|
||||
std::string err;
|
||||
trellis2_ss_dec_model * m = trellis2_ss_dec_load(gguf_path, true, &err);
|
||||
if (!m) { std::fprintf(stderr, "model load error: %s\n", err.c_str()); return 1; }
|
||||
std::printf("backend: %s\n", trellis2_ss_dec_backend_name(m));
|
||||
|
||||
const trellis2_ss_dec_hparams hp = trellis2_ss_dec_hparams_of(m);
|
||||
const int Rin = hp.res_in();
|
||||
const int Rout = hp.res_out();
|
||||
const size_t n_in = (size_t) hp.latent_channels * Rin * Rin * Rin;
|
||||
const size_t n_out = (size_t) hp.out_channels * Rout * Rout * Rout;
|
||||
|
||||
std::ifstream f(lat_path, std::ios::binary);
|
||||
if (!f) { std::fprintf(stderr, "cannot open latent %s\n", lat_path.c_str()); trellis2_ss_dec_free(m); return 1; }
|
||||
std::vector<float> latent(n_in);
|
||||
f.read(reinterpret_cast<char *>(latent.data()), (std::streamsize) (n_in * sizeof(float)));
|
||||
if ((size_t) f.gcount() != n_in * sizeof(float)) {
|
||||
std::fprintf(stderr, "latent size mismatch: got %zu floats, want %zu\n",
|
||||
(size_t) f.gcount() / sizeof(float), n_in);
|
||||
trellis2_ss_dec_free(m); return 1;
|
||||
}
|
||||
|
||||
std::vector<float> logits(n_out, 0.0f);
|
||||
if (!trellis2_ss_dec_decode(m, latent.data(), logits.data(), &err)) {
|
||||
std::fprintf(stderr, "decode error: %s\n", err.c_str());
|
||||
trellis2_ss_dec_free(m); return 1;
|
||||
}
|
||||
trellis2_ss_dec_free(m);
|
||||
|
||||
size_t occ = 0;
|
||||
for (float v : logits) if (v > iso) ++occ;
|
||||
std::printf("logits : [%d^3] occupied(>%.2f)=%zu/%zu (%.2f%%)\n",
|
||||
Rout, iso, occ, n_out, 100.0 * (double) occ / (double) n_out);
|
||||
|
||||
// Decoder output is channel-major [1, R, R, R] with linear index
|
||||
// i*R^2 + j*R + k (k fastest) -> matches marching_cubes' x + y*R + z*R^2 with
|
||||
// (x,y,z) = (k, j, i).
|
||||
mc::Mesh mesh = mc::extract(logits.data(), Rout, Rout, Rout, iso);
|
||||
std::printf("mesh : %zu verts, %zu tris\n", mesh.n_verts(), mesh.n_tris());
|
||||
|
||||
if (mesh.n_tris() == 0) {
|
||||
std::fprintf(stderr, "warning: empty surface at iso=%.3f (nothing to write)\n", iso);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (normalize) {
|
||||
for (size_t i = 0; i < mesh.verts.size(); ++i)
|
||||
mesh.verts[i] = mesh.verts[i] / (float) Rout - 0.5f;
|
||||
}
|
||||
|
||||
if (!mc::write_obj(mesh, out_path.c_str())) {
|
||||
std::fprintf(stderr, "failed to write %s\n", out_path.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("wrote %s\n", out_path.c_str());
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// ss_sample — run stage-1 sampling: a DINOv3 .dinodata cond + the SS-flow DiT
|
||||
// GGUF -> the sparse-structure latent z_s. The occupancy scaffold is z_s > 0.
|
||||
//
|
||||
// usage: ss_sample <ss_flow_dit.gguf> <cond.dinodata> [out.latent] [--seed N]
|
||||
//
|
||||
// Writes the z_s latent (channel-major [in_channels * R^3] float32) to out.latent
|
||||
// if given — the input the SS decoder (next stage) will consume.
|
||||
|
||||
#include "trellis2.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 3) {
|
||||
std::fprintf(stderr, "usage: %s <ss_flow_dit.gguf> <cond.dinodata> [out.latent] [--seed N]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
const std::string gguf_path = argv[1];
|
||||
const std::string dino_path = argv[2];
|
||||
std::string out_path;
|
||||
uint64_t seed = 0;
|
||||
for (int i = 3; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--seed") == 0 && i + 1 < argc) seed = std::strtoull(argv[++i], nullptr, 10);
|
||||
else if (out_path.empty()) out_path = argv[i];
|
||||
}
|
||||
|
||||
std::printf("trellis2.cpp %s\n", trellis2_version());
|
||||
|
||||
trellis2_dino_cond cond;
|
||||
std::string err;
|
||||
if (!trellis2_load_dinodata(dino_path, cond, &err)) {
|
||||
std::fprintf(stderr, "cond load error: %s\n", err.c_str());
|
||||
return 1;
|
||||
}
|
||||
std::printf("cond : %lld tokens x %lld channels\n",
|
||||
(long long) cond.tokens(), (long long) cond.channels());
|
||||
|
||||
trellis2_ss_flow_model * m = trellis2_ss_flow_load(gguf_path, true, &err);
|
||||
if (!m) { std::fprintf(stderr, "model load error: %s\n", err.c_str()); return 1; }
|
||||
std::printf("backend: %s\n", trellis2_ss_flow_backend_name(m));
|
||||
|
||||
const trellis2_ss_flow_hparams hp = trellis2_ss_flow_hparams_of(m); // copy (used after free)
|
||||
const size_t N = (size_t) hp.resolution * hp.resolution * hp.resolution;
|
||||
const size_t n = (size_t) hp.in_channels * N;
|
||||
|
||||
trellis2_ss_sampler_params P; // pipeline defaults (12 steps, gs 7.5, ...)
|
||||
P.seed = seed;
|
||||
|
||||
std::vector<float> latent(n, 0.0f);
|
||||
if (!trellis2_ss_flow_sample(m, cond.data.data(), (int) cond.tokens(), (int) cond.channels(),
|
||||
&P, /*noise*/ nullptr, latent.data(), &err)) {
|
||||
std::fprintf(stderr, "sample error: %s\n", err.c_str());
|
||||
trellis2_ss_flow_free(m);
|
||||
return 1;
|
||||
}
|
||||
trellis2_ss_flow_free(m);
|
||||
|
||||
double mn = 1e30, mx = -1e30, sum = 0.0;
|
||||
size_t occ = 0;
|
||||
for (float v : latent) { mn = v < mn ? v : mn; mx = v > mx ? v : mx; sum += v; if (v > 0.0f) ++occ; }
|
||||
std::printf("z_s : [%d,%d,%d,%d] min=%.4f max=%.4f mean=%.5f occupancy(>0)=%.2f%%\n",
|
||||
hp.in_channels, hp.resolution, hp.resolution, hp.resolution,
|
||||
mn, mx, sum / (double) n, 100.0 * (double) occ / (double) n);
|
||||
|
||||
if (!out_path.empty()) {
|
||||
std::ofstream f(out_path, std::ios::binary);
|
||||
f.write(reinterpret_cast<const char *>(latent.data()), (std::streamsize) (n * sizeof(float)));
|
||||
std::printf("wrote %s (%zu floats)\n", out_path.c_str(), n);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user