Kelsidavis-WoWee/tools/editor/cli_mesh_io.cpp
Kelsi 3d5a786ca9 refactor(editor): extract mesh ⇄ heightmap I/O into cli_mesh_io.cpp
Moves the three mesh-PNG bridge handlers (--displace-mesh,
--gen-mesh-from-heightmap, --export-mesh-heightmap) out of
main.cpp into their own translation unit. These three are
distinct from the gen-mesh-* primitive generators in that
they read or write external image files rather than synthesize
geometry from parameters alone.

main.cpp drops 21,061 → 20,741 lines (-320). Behavior verified
by re-running gen-mesh-from-heightmap → validate-wom → and
displace-mesh on a fresh plane.
2026-05-09 00:04:27 -07:00

375 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "cli_mesh_io.hpp"
#include "pipeline/wowee_model.hpp"
#include <glm/glm.hpp>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <string>
#include <vector>
// stb_image impl lives in stb_image_impl.cpp (separate TU);
// stb_image_write impl lives in texture_exporter.cpp.
// We just need the function decls here.
#include "stb_image.h"
#include "stb_image_write.h"
namespace wowee {
namespace editor {
namespace cli {
namespace {
int handleDisplaceMesh(int& i, int argc, char** argv) {
// Displaces each vertex along its current normal by the
// heightmap brightness × scale. UVs determine where each
// vertex samples the heightmap.
//
// Pairs naturally with --gen-mesh-grid: gen a flat grid,
// then --displace-mesh with a noise PNG to create
// procedural terrain. Or use it on a sphere to make a
// bumpy planet.
std::string womBase = argv[++i];
std::string pngPath = argv[++i];
float scale = 1.0f;
if (i + 1 < argc && argv[i + 1][0] != '-') {
try { scale = std::stof(argv[++i]); } catch (...) {}
}
if (!std::isfinite(scale)) scale = 1.0f;
if (womBase.size() >= 4 &&
womBase.substr(womBase.size() - 4) == ".wom") {
womBase = womBase.substr(0, womBase.size() - 4);
}
namespace fs = std::filesystem;
if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) {
std::fprintf(stderr,
"displace-mesh: %s.wom does not exist\n", womBase.c_str());
return 1;
}
int W, H, comp;
uint8_t* data = stbi_load(pngPath.c_str(), &W, &H, &comp, 1);
if (!data) {
std::fprintf(stderr,
"displace-mesh: cannot read %s (%s)\n",
pngPath.c_str(), stbi_failure_reason());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(womBase);
if (!wom.isValid()) {
std::fprintf(stderr,
"displace-mesh: failed to load %s.wom\n", womBase.c_str());
stbi_image_free(data);
return 1;
}
float minDelta = 1e30f, maxDelta = -1e30f;
for (auto& v : wom.vertices) {
// Sample the heightmap with bilinear filtering at
// (u, v). Wrap repeating UVs.
float u = v.texCoord.x - std::floor(v.texCoord.x);
float vv = v.texCoord.y - std::floor(v.texCoord.y);
float fx = u * (W - 1);
float fy = vv * (H - 1);
int x0 = static_cast<int>(fx);
int y0 = static_cast<int>(fy);
int x1 = std::min(x0 + 1, W - 1);
int y1 = std::min(y0 + 1, H - 1);
float tx = fx - x0;
float ty = fy - y0;
auto sample = [&](int x, int y) {
return data[y * W + x] / 255.0f;
};
float a = sample(x0, y0);
float b = sample(x1, y0);
float c = sample(x0, y1);
float d = sample(x1, y1);
float ab = a + (b - a) * tx;
float cd = c + (d - c) * tx;
float h = ab + (cd - ab) * ty;
float delta = h * scale;
v.position += v.normal * delta;
if (delta < minDelta) minDelta = delta;
if (delta > maxDelta) maxDelta = delta;
}
stbi_image_free(data);
// Recompute bounds; normals stay (they're now stale to
// the displaced surface but the user can run --smooth-
// mesh-normals if they want shading to follow the bumps).
wom.boundMin = glm::vec3(1e30f);
wom.boundMax = glm::vec3(-1e30f);
for (const auto& v : wom.vertices) {
wom.boundMin = glm::min(wom.boundMin, v.position);
wom.boundMax = glm::max(wom.boundMax, v.position);
}
wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f;
if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) {
std::fprintf(stderr,
"displace-mesh: failed to save %s.wom\n", womBase.c_str());
return 1;
}
std::printf("Displaced %s.wom with %s\n",
womBase.c_str(), pngPath.c_str());
std::printf(" source PNG : %dx%d\n", W, H);
std::printf(" scale : %g\n", scale);
std::printf(" vertices : %zu touched\n", wom.vertices.size());
std::printf(" delta : %.3f to %.3f\n", minDelta, maxDelta);
std::printf(" new bounds : (%.3f, %.3f, %.3f) - (%.3f, %.3f, %.3f)\n",
wom.boundMin.x, wom.boundMin.y, wom.boundMin.z,
wom.boundMax.x, wom.boundMax.y, wom.boundMax.z);
std::printf(" hint : run --smooth-mesh-normals so shading follows the bumps\n");
return 0;
}
int handleGenMeshFromHeightmap(int& i, int argc, char** argv) {
// Convert a grayscale PNG into a heightmap mesh. Each
// pixel becomes one vertex; brightness becomes Y. The
// mesh is centered on the XZ plane with X spanning
// [-W*scaleXZ/2, +W*scaleXZ/2] and Z spanning the same
// for H. Default scaleXZ=0.1 (so a 64×64 PNG covers a
// 6.4×6.4 yard patch) and scaleY=2.0 (so full white
// pixels rise 2 yards above black).
//
// Normals are computed from finite differences against
// the height field — gives smooth shading across the
// surface. Single batch covers all indices; one empty
// texture slot for downstream binding via --add-
// texture-to-mesh.
std::string womBase = argv[++i];
std::string pngPath = argv[++i];
float scaleXZ = 0.1f;
float scaleY = 2.0f;
if (i + 1 < argc && argv[i + 1][0] != '-') {
try { scaleXZ = std::stof(argv[++i]); } catch (...) {}
}
if (i + 1 < argc && argv[i + 1][0] != '-') {
try { scaleY = std::stof(argv[++i]); } catch (...) {}
}
if (scaleXZ <= 0 || !std::isfinite(scaleXZ) ||
!std::isfinite(scaleY)) {
std::fprintf(stderr,
"gen-mesh-from-heightmap: scales must be finite, scaleXZ > 0\n");
return 1;
}
if (womBase.size() >= 4 &&
womBase.substr(womBase.size() - 4) == ".wom") {
womBase = womBase.substr(0, womBase.size() - 4);
}
int W, H, comp;
// Force 1-channel grayscale on read; stb downsamples
// automatically.
uint8_t* data = stbi_load(pngPath.c_str(), &W, &H, &comp, 1);
if (!data) {
std::fprintf(stderr,
"gen-mesh-from-heightmap: cannot read %s (%s)\n",
pngPath.c_str(), stbi_failure_reason());
return 1;
}
if (W < 2 || H < 2) {
std::fprintf(stderr,
"gen-mesh-from-heightmap: image must be at least 2x2 (got %dx%d)\n",
W, H);
stbi_image_free(data);
return 1;
}
// Capacity guard: a 1024x1024 PNG would be 1M verts /
// ~6M tris — well past what makes sense for a single
// WOM placeholder. Cap at 512×512 = 262K verts.
if (W > 512 || H > 512) {
std::fprintf(stderr,
"gen-mesh-from-heightmap: image too large (%dx%d > 512x512)\n",
W, H);
stbi_image_free(data);
return 1;
}
wowee::pipeline::WoweeModel wom;
wom.name = std::filesystem::path(womBase).stem().string();
wom.version = 3;
float halfW = W * scaleXZ * 0.5f;
float halfH = H * scaleXZ * 0.5f;
auto sample = [&](int x, int y) {
if (x < 0) x = 0; if (x >= W) x = W - 1;
if (y < 0) y = 0; if (y >= H) y = H - 1;
return data[y * W + x] / 255.0f * scaleY;
};
wom.vertices.reserve(static_cast<size_t>(W) * H);
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
float h = sample(x, y);
// Central-difference normal: (-dh/dx, 1, -dh/dz),
// normalized.
float dx = (sample(x + 1, y) - sample(x - 1, y)) /
(2.0f * scaleXZ);
float dz = (sample(x, y + 1) - sample(x, y - 1)) /
(2.0f * scaleXZ);
glm::vec3 n(-dx, 1.0f, -dz);
n = glm::normalize(n);
wowee::pipeline::WoweeModel::Vertex v;
v.position = glm::vec3(x * scaleXZ - halfW,
h,
y * scaleXZ - halfH);
v.normal = n;
v.texCoord = glm::vec2(static_cast<float>(x) / (W - 1),
static_cast<float>(y) / (H - 1));
wom.vertices.push_back(v);
}
}
wom.indices.reserve(static_cast<size_t>(W - 1) * (H - 1) * 6);
for (int y = 0; y < H - 1; ++y) {
for (int x = 0; x < W - 1; ++x) {
uint32_t a = y * W + x;
uint32_t b = a + 1;
uint32_t c = a + W;
uint32_t d = c + 1;
wom.indices.push_back(a);
wom.indices.push_back(c);
wom.indices.push_back(b);
wom.indices.push_back(b);
wom.indices.push_back(c);
wom.indices.push_back(d);
}
}
stbi_image_free(data);
// Bounds from vertex extents.
wom.boundMin = glm::vec3(1e30f);
wom.boundMax = glm::vec3(-1e30f);
for (const auto& v : wom.vertices) {
wom.boundMin = glm::min(wom.boundMin, v.position);
wom.boundMax = glm::max(wom.boundMax, v.position);
}
wom.boundRadius = glm::length(wom.boundMax - wom.boundMin) * 0.5f;
wowee::pipeline::WoweeModel::Batch b;
b.indexStart = 0;
b.indexCount = static_cast<uint32_t>(wom.indices.size());
b.textureIndex = 0;
b.blendMode = 0;
b.flags = 0;
wom.batches.push_back(b);
wom.texturePaths.push_back("");
std::filesystem::path womPath(womBase);
std::filesystem::create_directories(womPath.parent_path());
if (!wowee::pipeline::WoweeModelLoader::save(wom, womBase)) {
std::fprintf(stderr,
"gen-mesh-from-heightmap: failed to save %s.wom\n",
womBase.c_str());
return 1;
}
std::printf("Wrote %s.wom from %s\n",
womBase.c_str(), pngPath.c_str());
std::printf(" source PNG : %dx%d\n", W, H);
std::printf(" scaleXZ : %g (mesh span %.2f × %.2f)\n",
scaleXZ, W * scaleXZ, H * scaleXZ);
std::printf(" scaleY : %g (height range %.3f to %.3f)\n",
scaleY, wom.boundMin.y, wom.boundMax.y);
std::printf(" vertices : %zu\n", wom.vertices.size());
std::printf(" triangles : %zu\n", wom.indices.size() / 3);
return 0;
}
int handleExportMeshHeightmap(int& i, int argc, char** argv) {
// Inverse of --gen-mesh-from-heightmap: extract a
// grayscale PNG from a row-major W×H heightmap mesh.
// The user supplies W and H since arbitrary meshes
// aren't necessarily heightmap-shaped — taking the
// dimensions explicitly avoids guessing wrong on a
// mesh with vertex count W*H but a different layout.
//
// Y values are normalized to 0..255 using the mesh
// bounds (Y_min → 0, Y_max → 255). Round-trips with
// --gen-mesh-from-heightmap modulo the 1-byte
// quantization step.
std::string womBase = argv[++i];
std::string outPath = argv[++i];
int W = 0, H = 0;
try {
W = std::stoi(argv[++i]);
H = std::stoi(argv[++i]);
} catch (...) {
std::fprintf(stderr,
"export-mesh-heightmap: W and H must be integers\n");
return 1;
}
if (W < 2 || H < 2 || W > 8192 || H > 8192) {
std::fprintf(stderr,
"export-mesh-heightmap: W and H must be 2..8192\n");
return 1;
}
if (womBase.size() >= 4 &&
womBase.substr(womBase.size() - 4) == ".wom") {
womBase = womBase.substr(0, womBase.size() - 4);
}
if (!wowee::pipeline::WoweeModelLoader::exists(womBase)) {
std::fprintf(stderr,
"export-mesh-heightmap: %s.wom does not exist\n",
womBase.c_str());
return 1;
}
auto wom = wowee::pipeline::WoweeModelLoader::load(womBase);
if (!wom.isValid()) {
std::fprintf(stderr,
"export-mesh-heightmap: failed to load %s.wom\n",
womBase.c_str());
return 1;
}
size_t expected = static_cast<size_t>(W) * H;
if (wom.vertices.size() < expected) {
std::fprintf(stderr,
"export-mesh-heightmap: %s.wom has %zu vertices, "
"need at least %zu for %dx%d\n",
womBase.c_str(), wom.vertices.size(), expected, W, H);
return 1;
}
float yMin = wom.boundMin.y;
float yMax = wom.boundMax.y;
float range = yMax - yMin;
std::vector<uint8_t> pixels(expected * 3, 0);
for (int y = 0; y < H; ++y) {
for (int x = 0; x < W; ++x) {
size_t idx = static_cast<size_t>(y) * W + x;
float h = wom.vertices[idx].position.y;
float t = (range > 1e-6f) ? (h - yMin) / range : 0.0f;
if (t < 0) t = 0; if (t > 1) t = 1;
uint8_t g = static_cast<uint8_t>(t * 255.0f + 0.5f);
size_t i2 = idx * 3;
pixels[i2 + 0] = g;
pixels[i2 + 1] = g;
pixels[i2 + 2] = g;
}
}
if (!stbi_write_png(outPath.c_str(), W, H, 3,
pixels.data(), W * 3)) {
std::fprintf(stderr,
"export-mesh-heightmap: stbi_write_png failed for %s\n",
outPath.c_str());
return 1;
}
std::printf("Wrote %s from %s.wom\n",
outPath.c_str(), womBase.c_str());
std::printf(" size : %dx%d\n", W, H);
std::printf(" height : %.3f to %.3f (mapped to 0..255)\n",
yMin, yMax);
std::printf(" pixels : %zu (W*H)\n", expected);
return 0;
}
} // namespace
bool handleMeshIO(int& i, int argc, char** argv, int& outRc) {
if (std::strcmp(argv[i], "--displace-mesh") == 0 && i + 2 < argc) {
outRc = handleDisplaceMesh(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--gen-mesh-from-heightmap") == 0 && i + 2 < argc) {
outRc = handleGenMeshFromHeightmap(i, argc, argv); return true;
}
if (std::strcmp(argv[i], "--export-mesh-heightmap") == 0 && i + 4 < argc) {
outRc = handleExportMeshHeightmap(i, argc, argv); return true;
}
return false;
}
} // namespace cli
} // namespace editor
} // namespace wowee