feat(editor): add --export-wob-obj for buildings -> Wavefront OBJ

WOM (the open M2 replacement) already round-trips through OBJ; this
extends the universal-format bridge to WOB (the open WMO replacement)
so buildings can also be edited in Blender / MeshLab / Maya / etc.

  wowee_editor --export-wob-obj House         # writes House.obj
  wowee_editor --export-wob-obj House out.obj # custom path

Mapping decisions:
- Each WOB group becomes one OBJ 'g' block (named after the group;
  outdoor groups get an '_outdoor' suffix). Preserves the room/floor
  structure for downstream selection and per-area editing.
- Single global vertex pool with per-group offsets (OBJ requires v
  indices to be globally 1-based; we track a running vertOffset).
- UV V flipped (1.0 - v) so texturing matches Blender bottom-left
  convention, same as --export-obj for WOM.
- Doodad placements written as # comment lines at the end. OBJ has
  no native concept for instanced models, but emitting them as
  structured comments keeps the placement data recoverable for
  tools that want to re-instance them.
- Portals and material flags drop on the floor — OBJ has no
  semantics for either. The native WOB always remains canonical.

Verified on a synthesized 2-group house (4-vert floor + 3-vert wall,
1 doodad): output OBJ has 7 verts / 7 vt / 7 vn entries, 2 'g'
blocks with proper index offsetting, doodad comment line preserved.
This commit is contained in:
Kelsi 2026-05-06 12:14:04 -07:00
parent caa0df7e5e
commit 23a2233852

View file

@ -429,6 +429,8 @@ static void printUsage(const char* argv0) {
std::printf(" Convert a WOM model to Wavefront OBJ for use in Blender/MeshLab\n");
std::printf(" --import-obj <obj-path> [wom-base]\n");
std::printf(" Convert a Wavefront OBJ back into WOM (round-trips with --export-obj)\n");
std::printf(" --export-wob-obj <wob-base> [out.obj]\n");
std::printf(" Convert a WOB building to Wavefront OBJ (one group per WOB group)\n");
std::printf(" --validate <zoneDir> [--json]\n");
std::printf(" Score zone open-format completeness and exit\n");
std::printf(" --validate-wom <wom-base> [--json]\n");
@ -504,7 +506,7 @@ int main(int argc, char* argv[]) {
"--remove-creature", "--remove-object", "--remove-quest",
"--copy-zone",
"--build-woc", "--regen-collision", "--fix-zone",
"--export-png", "--export-obj", "--import-obj",
"--export-png", "--export-obj", "--import-obj", "--export-wob-obj",
"--convert-m2", "--convert-wmo",
};
for (int i = 1; i < argc; i++) {
@ -2116,6 +2118,101 @@ int main(int argc, char* argv[]) {
wom.vertices.size(), wom.indices.size() / 3,
wom.batches.empty() ? size_t(1) : wom.batches.size());
return 0;
} else if (std::strcmp(argv[i], "--export-wob-obj") == 0 && i + 1 < argc) {
// WOB is the WMO replacement; like --export-obj for WOM, this
// bridges WOB into the universal-3D-tool ecosystem. Each WOB
// group becomes one OBJ 'g' block, preserving the room/floor
// structure for downstream selection in Blender/MeshLab.
std::string base = argv[++i];
std::string outPath;
if (i + 1 < argc && argv[i + 1][0] != '-') {
outPath = argv[++i];
}
if (base.size() >= 4 && base.substr(base.size() - 4) == ".wob")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeBuildingLoader::exists(base)) {
std::fprintf(stderr, "WOB not found: %s.wob\n", base.c_str());
return 1;
}
if (outPath.empty()) outPath = base + ".obj";
auto bld = wowee::pipeline::WoweeBuildingLoader::load(base);
if (!bld.isValid()) {
std::fprintf(stderr, "WOB has no groups to export: %s.wob\n", base.c_str());
return 1;
}
std::ofstream obj(outPath);
if (!obj) {
std::fprintf(stderr, "Failed to open output file: %s\n", outPath.c_str());
return 1;
}
// Total verts/tris across all groups for the header.
size_t totalV = 0, totalI = 0;
for (const auto& g : bld.groups) {
totalV += g.vertices.size();
totalI += g.indices.size();
}
obj << "# Wavefront OBJ generated by wowee_editor --export-wob-obj\n";
obj << "# Source: " << base << ".wob\n";
obj << "# Groups: " << bld.groups.size()
<< " Verts: " << totalV
<< " Tris: " << totalI / 3
<< " Portals: " << bld.portals.size()
<< " Doodads: " << bld.doodads.size() << "\n\n";
obj << "o " << (bld.name.empty() ? "WoweeBuilding" : bld.name) << "\n";
// OBJ uses a single global vertex pool, so we offset each group's
// local indices by the running total of verts written so far.
uint32_t vertOffset = 0;
for (size_t g = 0; g < bld.groups.size(); ++g) {
const auto& grp = bld.groups[g];
if (grp.vertices.empty()) continue;
for (const auto& v : grp.vertices) {
obj << "v " << v.position.x << " "
<< v.position.y << " "
<< v.position.z << "\n";
}
for (const auto& v : grp.vertices) {
obj << "vt " << v.texCoord.x << " "
<< (1.0f - v.texCoord.y) << "\n";
}
for (const auto& v : grp.vertices) {
obj << "vn " << v.normal.x << " "
<< v.normal.y << " "
<< v.normal.z << "\n";
}
std::string groupName = grp.name.empty()
? "group_" + std::to_string(g)
: grp.name;
if (grp.isOutdoor) groupName += "_outdoor";
obj << "g " << groupName << "\n";
for (size_t k = 0; k + 2 < grp.indices.size(); k += 3) {
uint32_t i0 = grp.indices[k] + 1 + vertOffset;
uint32_t i1 = grp.indices[k + 1] + 1 + vertOffset;
uint32_t i2 = grp.indices[k + 2] + 1 + vertOffset;
obj << "f "
<< i0 << "/" << i0 << "/" << i0 << " "
<< i1 << "/" << i1 << "/" << i1 << " "
<< i2 << "/" << i2 << "/" << i2 << "\n";
}
vertOffset += static_cast<uint32_t>(grp.vertices.size());
}
// Doodad placements as a separate informational block — emit
// each as a comment line so OBJ stays valid but the data is
// recoverable for tools that want to re-create the placements.
if (!bld.doodads.empty()) {
obj << "\n# Doodad placements (model, position, rotation, scale):\n";
for (const auto& d : bld.doodads) {
obj << "# doodad " << d.modelPath
<< " pos " << d.position.x << "," << d.position.y << "," << d.position.z
<< " rot " << d.rotation.x << "," << d.rotation.y << "," << d.rotation.z
<< " scale " << d.scale << "\n";
}
}
obj.close();
std::printf("Exported %s.wob -> %s\n", base.c_str(), outPath.c_str());
std::printf(" %zu groups, %zu verts, %zu tris, %zu doodad placements\n",
bld.groups.size(), totalV, totalI / 3,
bld.doodads.size());
return 0;
} else if (std::strcmp(argv[i], "--import-obj") == 0 && i + 1 < argc) {
// Convert a Wavefront OBJ back into WOM. Round-trips with
// --export-obj for the geometry/UV/normal data; bones,