feat(editor): add --export-bones-dot for Graphviz bone-tree visualization

Renders WOM bone hierarchy as Graphviz DOT. Mirrors --export-quest-graph
for skeleton trees: a 50-bone tree from --info-bones is hard to read in
text; pipe this through 'dot -Tpng' for the picture:

  wowee_editor --export-bones-dot HumanMale
  dot -Tpng HumanMale.bones.dot -o bones.png

Visual encoding:
- lightgreen fill: keybones (named anchor points referenced by gameplay
  systems — head, hands, feet, etc.)
- lightgrey fill: internal/blend bones (non-key, used for shape only)
- goldenrod border: root bones (parent=-1, top of skeleton hierarchy)

Edges flow parent -> child (rankdir=TB so root is at top, leaves at
bottom).

Useful for skeleton-debugging:
- 'why is this finger not following its hand?' -> see the parent chain
- 'is this bone really a root or did its parent get deleted?' ->
  goldenrod border makes intentional roots vs accidental ones obvious
- understanding which bones gameplay code can reference by key id

Verified on a 5-bone synthesized skeleton (3-deep chain + 1 detached
root + mix of key/internal): DOT correctly emits 5 nodes with the
right colors (3 lightgreen for key bones, 2 lightgrey for internal,
2 with goldenrod root borders), 3 edges traversing the chain.
This commit is contained in:
Kelsi 2026-05-06 19:35:32 -07:00
parent 8d9690a57a
commit 20bd417002

View file

@ -701,6 +701,8 @@ static void printUsage(const char* argv0) {
std::printf(" List M2 animation sequences (id, duration, flags)\n");
std::printf(" --info-bones <m2-path> [--json]\n");
std::printf(" List M2 bones with parent tree, key-bone IDs, pivot offsets\n");
std::printf(" --export-bones-dot <wom-base> [out.dot]\n");
std::printf(" Render WOM bone hierarchy as Graphviz DOT (pipe to `dot -Tpng -o bones.png`)\n");
std::printf(" --list-zone-textures <zoneDir> [--json]\n");
std::printf(" Aggregate texture refs across all WOM models in a zone (deduped)\n");
std::printf(" --info-wob <wob-base> [--json]\n");
@ -820,7 +822,7 @@ int main(int argc, char* argv[]) {
static const char* kArgRequired[] = {
"--data", "--info", "--info-batches", "--info-textures", "--info-doodads",
"--info-attachments", "--info-particles", "--info-sequences",
"--info-bones", "--list-zone-textures",
"--info-bones", "--export-bones-dot", "--list-zone-textures",
"--info-wob", "--info-woc", "--info-wot",
"--info-creatures", "--info-objects", "--info-quests",
"--info-extract", "--info-extract-tree", "--info-extract-budget",
@ -1526,6 +1528,63 @@ int main(int argc, char* argv[]) {
b.pivot.x, b.pivot.y, b.pivot.z);
}
return 0;
} else if (std::strcmp(argv[i], "--export-bones-dot") == 0 && i + 1 < argc) {
// Render WOM bone hierarchy as Graphviz DOT. Mirrors
// --export-quest-graph for skeleton trees: trying to read
// a 50-bone tree from --info-bones output is painful;
// pipe this through `dot -Tpng` for the picture.
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) == ".wom")
base = base.substr(0, base.size() - 4);
if (!wowee::pipeline::WoweeModelLoader::exists(base)) {
std::fprintf(stderr,
"export-bones-dot: WOM not found: %s.wom\n", base.c_str());
return 1;
}
if (outPath.empty()) outPath = base + ".bones.dot";
auto wom = wowee::pipeline::WoweeModelLoader::load(base);
std::ofstream out(outPath);
if (!out) {
std::fprintf(stderr,
"export-bones-dot: cannot write %s\n", outPath.c_str());
return 1;
}
out << "digraph BoneTree {\n";
out << " // Generated by wowee_editor --export-bones-dot\n";
out << " rankdir=TB;\n";
out << " node [shape=box, style=filled, fontname=\"sans-serif\", fontsize=10];\n";
// Color: green for keybones (named anchor points), gray for
// internal/blend bones. Root bones (parent=-1) get yellow border.
for (size_t k = 0; k < wom.bones.size(); ++k) {
const auto& b = wom.bones[k];
bool isKey = (b.keyBoneId >= 0);
std::string fill = isKey ? "lightgreen" : "lightgrey";
std::string label = "[" + std::to_string(k) + "]";
if (isKey) label += "\\nkey=" + std::to_string(b.keyBoneId);
out << " b" << k << " [label=\"" << label
<< "\", fillcolor=" << fill;
if (b.parentBone == -1) out << ", penwidth=2, color=goldenrod";
out << "];\n";
}
// Edges: child -> parent (parent is up).
int rootCount = 0;
for (size_t k = 0; k < wom.bones.size(); ++k) {
int16_t p = wom.bones[k].parentBone;
if (p < 0 || p >= static_cast<int16_t>(wom.bones.size())) {
rootCount++;
continue;
}
out << " b" << p << " -> b" << k << ";\n";
}
out << "}\n";
out.close();
std::printf("Wrote %s\n", outPath.c_str());
std::printf(" %zu bones, %d root(s)\n",
wom.bones.size(), rootCount);
std::printf(" next: dot -Tpng %s -o bones.png\n", outPath.c_str());
return 0;
} else if (std::strcmp(argv[i], "--list-zone-textures") == 0 && i + 1 < argc) {
// Aggregate texture references across every WOM model in a
// zone directory. Companion to --list-zone-deps (which lists