WoW Model Viewer
Your premiere tool for viewing, equipping and animating World of Warcraft models.
Loading...
Searching...
No Matches
CharacterViewerPanel.cpp
Go to the documentation of this file.
1// ============================================================================
2// CharacterViewerPanel – standalone Character Viewer tab (wow.export-style)
3//
4// Three-column layout: Race/Customization (left), 3D Viewport with animation
5// bar (center), Equipment placeholder (right).
6// ============================================================================
8
9#include <cassert>
10
11#include "imgui.h"
12#include "imgui_internal.h"
13
14#include "WoWModel.h"
15#include "OrbitCamera.h"
16#include "ViewportFBO.h"
17#include "AppSettings.h"
18#include "Renderer.h"
19#include "Attachment.h"
20#include "WoWDatabase.h"
21#include "WoWFolder.h"
22#include "Game.h"
23#include "CharDetails.h"
24#include "DB2Table.h"
25#include "Logger.h"
26#include "string_utils.h"
27
28#include <algorithm>
29#include <map>
30#include <set>
31#include <string>
32#include <vector>
33
34namespace
35{
36
37// ---- Character Browser race data ------------------------------------------
38struct CharBrowserRace
39{
40 int raceID = 0;
41 std::string name;
42};
43
44// ---- Body type data (matches wow.export ChrRaceXChrModel flow) ------------
45struct BodyType
46{
47 int chrModelID = 0;
48 int fileDataID = 0;
49 std::string label;
50};
51
52std::vector<CharBrowserRace> s_races;
53std::vector<BodyType> s_bodyTypes;
54std::vector<size_t> s_filtered;
55bool s_built = false;
56bool s_filterDirty = true;
57char s_searchBuf[256] = {};
58
59// ---- Character Viewer state -----------------------------------------------
60int s_selectedRaceIdx = -1;
61int s_selectedBodyType = -1;
62float s_leftWidth = 280.0f;
63float s_rightWidth = 220.0f;
64
65// ---- Helpers --------------------------------------------------------------
66
69void buildRaceList()
70{
71 s_races.clear();
72
73 const auto* chrRaces = WOWDB.getTable("ChrRaces");
74 const auto* xTable = WOWDB.getTable("ChrRaceXChrModel");
75 if (!chrRaces || !xTable) return;
76
77 // Collect race IDs that have at least one model
78 std::set<int> raceIdsWithModels;
79 for (const auto& row : *xTable)
80 raceIdsWithModels.insert(static_cast<int>(row.getUInt("ChrRacesID")));
81
82 std::map<int, CharBrowserRace> raceMap;
83 for (int rid : raceIdsWithModels)
84 {
85 auto row = chrRaces->getRow(static_cast<uint32_t>(rid));
86 if (!row)
87 continue;
88
89 CharBrowserRace race;
90 race.raceID = rid;
91 race.name = row.getString("Name_Lang");
92 if (race.name.empty())
93 race.name = "Race " + std::to_string(rid);
94
95 raceMap[rid] = std::move(race);
96 }
97
98 for (auto& [id, race] : raceMap)
99 s_races.push_back(std::move(race));
100
101 std::sort(s_races.begin(), s_races.end(),
102 [](const CharBrowserRace& a, const CharBrowserRace& b)
103 { return a.name < b.name; });
104
105 s_filterDirty = true;
106}
107
110void buildBodyTypes(int raceID)
111{
112 s_bodyTypes.clear();
113 s_selectedBodyType = -1;
114
115 const auto* xTable = WOWDB.getTable("ChrRaceXChrModel");
116 const auto* chrModel = WOWDB.getTable("ChrModel");
117 const auto* cdi = WOWDB.getTable("CreatureDisplayInfo");
118 const auto* cmd = WOWDB.getTable("CreatureModelData");
119 if (!xTable || !chrModel || !cdi || !cmd) return;
120
121 struct ModelEntry { int sex; int chrModelID; };
122 std::vector<ModelEntry> models;
123
124 for (const auto& row : *xTable)
125 {
126 if (static_cast<int>(row.getUInt("ChrRacesID")) == raceID)
127 {
128 const int modelID = static_cast<int>(row.getUInt("ChrModelID"));
129 DB2Row modelRow = chrModel->getRow(static_cast<uint32_t>(modelID));
130 if (!modelRow) continue;
131 const int sex = static_cast<int>(modelRow.getUInt("Sex"));
132 models.push_back({ sex, modelID });
133 }
134 }
135
136 std::sort(models.begin(), models.end(),
137 [](const ModelEntry& a, const ModelEntry& b) { return a.sex < b.sex; });
138
139 for (const auto& m : models)
140 {
141 DB2Row modelRow = chrModel->getRow(static_cast<uint32_t>(m.chrModelID));
142 if (!modelRow) continue;
143
144 DB2Row displayRow = cdi->getRow(modelRow.getUInt("DisplayID"));
145 if (!displayRow) continue;
146
147 DB2Row modelDataRow = cmd->getRow(displayRow.getUInt("ModelID"));
148 if (!modelDataRow) continue;
149
150 BodyType bt;
151 bt.chrModelID = m.chrModelID;
152 bt.fileDataID = static_cast<int>(modelDataRow.getUInt("FileDataID"));
153 bt.label = (m.sex == 0) ? "Male" : (m.sex == 1) ? "Female" : "Body " + std::to_string(m.sex + 1);
154 s_bodyTypes.push_back(std::move(bt));
155 }
156}
157
158void rebuildFilter()
159{
160 s_filtered.clear();
161
162 std::string search = core::toLower(std::string(s_searchBuf));
163 auto s = search.find_first_not_of(" \t\r\n");
164 auto e = search.find_last_not_of(" \t\r\n");
165 search = (s == std::string::npos) ? "" : search.substr(s, e - s + 1);
166
167 for (size_t i = 0; i < s_races.size(); ++i)
168 {
169 if (!search.empty() && !core::containsIgnoreCase(s_races[i].name, search))
170 continue;
171 s_filtered.push_back(i);
172 }
173
174 s_filterDirty = false;
175}
176
177void loadBodyType(int fileDataID, const CharacterViewerPanel::DrawContext& ctx)
178{
179 if (fileDataID > 0)
180 {
181 GameFile* file = GAMEDIRECTORY.getFile(fileDataID);
182 if (file && ctx.loadModel)
183 ctx.loadModel(file);
184 }
185}
186
187} // anonymous namespace
188
189// ============================================================================
190// Public API
191// ============================================================================
193{
194
195void draw(const DrawContext& ctx)
196{
197 assert(ctx.customizationOptions && "DrawContext::customizationOptions must not be null");
198 assert(ctx.animEntries && "DrawContext::animEntries must not be null");
199 assert(ctx.selectedAnimCombo && "DrawContext::selectedAnimCombo must not be null");
200 assert(ctx.fbo && "DrawContext::fbo must not be null");
201 assert(ctx.camera && "DrawContext::camera must not be null");
202
203 if (!ctx.isWoWLoaded || !ctx.isDBReady)
204 {
205 ImGui::TextDisabled("Game not loaded.");
206 return;
207 }
208
209 // Lazy-init race list
210 if (!s_built)
211 {
212 buildRaceList();
213 s_built = true;
214 }
215
216 WoWModel* model = ctx.getLoadedModel ? ctx.getLoadedModel() : nullptr;
217 const bool isChar = model && ctx.isChar;
218
219 const ImVec2 avail = ImGui::GetContentRegionAvail();
220 const float spacing = ImGui::GetStyle().ItemSpacing.x;
221 const float centerW = avail.x - s_leftWidth - s_rightWidth - spacing * 2.0f;
222
223 // =================================================================
224 // LEFT COLUMN – Race selector + Customization
225 // =================================================================
226 ImGui::BeginChild("##cvLeft", ImVec2(s_leftWidth, -1), ImGuiChildFlags_Borders);
227 {
228 // ---- Race Selector ----
229 ImGui::SeparatorText("Race");
230
231 const char* racePreview =
232 (s_selectedRaceIdx >= 0 &&
233 s_selectedRaceIdx < static_cast<int>(s_races.size()))
234 ? s_races[s_selectedRaceIdx].name.c_str()
235 : "<select race>";
236
237 ImGui::SetNextItemWidth(-1);
238 if (ImGui::BeginCombo("##cvRace", racePreview))
239 {
240 for (int i = 0; i < static_cast<int>(s_races.size()); ++i)
241 {
242 const auto& race = s_races[i];
243 bool selected = (i == s_selectedRaceIdx);
244 std::string label = race.name + "##race" + std::to_string(race.raceID);
245 if (ImGui::Selectable(label.c_str(), selected) && !selected)
246 {
247 s_selectedRaceIdx = i;
248 buildBodyTypes(race.raceID);
249 if (!s_bodyTypes.empty())
250 {
251 s_selectedBodyType = 0;
252 loadBodyType(s_bodyTypes[0].fileDataID, ctx);
253 }
254 }
255 if (selected) ImGui::SetItemDefaultFocus();
256 }
257 ImGui::EndCombo();
258 }
259
260 // Body type selector (populated from ChrRaceXChrModel)
261 if (!s_bodyTypes.empty())
262 {
263 const char* bodyPreview =
264 (s_selectedBodyType >= 0 &&
265 s_selectedBodyType < static_cast<int>(s_bodyTypes.size()))
266 ? s_bodyTypes[s_selectedBodyType].label.c_str()
267 : "<select body>";
268
269 ImGui::SetNextItemWidth(-1);
270 if (ImGui::BeginCombo("##cvBodyType", bodyPreview))
271 {
272 for (int i = 0; i < static_cast<int>(s_bodyTypes.size()); ++i)
273 {
274 bool selected = (i == s_selectedBodyType);
275 std::string bodyLabel = s_bodyTypes[i].label + "##body" + std::to_string(s_bodyTypes[i].chrModelID);
276 if (ImGui::Selectable(bodyLabel.c_str(), selected) && !selected)
277 {
278 s_selectedBodyType = i;
279 loadBodyType(s_bodyTypes[i].fileDataID, ctx);
280 }
281 if (selected) ImGui::SetItemDefaultFocus();
282 }
283 ImGui::EndCombo();
284 }
285 }
286
287 // ---- Customization Options (grouped by category) ----
288 if (isChar && ctx.customizationOptions && !ctx.customizationOptions->empty())
289 {
290 // Try to load ChrCustomizationCategory table for section names
291 const auto* catTable = WOWDB.getTable("ChrCustomizationCategory");
292
293 unsigned int lastCatID = UINT_MAX;
294 for (auto& opt : *ctx.customizationOptions)
295 {
296 if (opt.choiceNames.empty()) continue;
297
298 // Show section header when category changes
299 if (opt.categoryID != lastCatID)
300 {
301 lastCatID = opt.categoryID;
302 std::string catName;
303 if (catTable)
304 {
305 auto catRow = catTable->getRow(opt.categoryID);
306 if (catRow)
307 catName = catRow.getString("CategoryName_Lang");
308 }
309 if (catName.empty())
310 catName = "Customization";
311 ImGui::SeparatorText(catName.c_str());
312 }
313
314 const char* preview =
315 (opt.selectedIndex >= 0 &&
316 opt.selectedIndex < static_cast<int>(opt.choiceNames.size()))
317 ? opt.choiceNames[opt.selectedIndex].c_str()
318 : "<none>";
319 ImGui::Text("%s:", opt.name.c_str());
320 ImGui::SetNextItemWidth(-1);
321 std::string comboID = "##cvOpt" + std::to_string(opt.optionID);
322 if (ImGui::BeginCombo(comboID.c_str(), preview))
323 {
324 for (int c = 0; c < static_cast<int>(opt.choiceNames.size()); ++c)
325 {
326 bool sel = (c == opt.selectedIndex);
327 std::string choiceLabel = opt.choiceNames[c] + "##ch" + std::to_string(opt.choiceIDs[c]);
328 if (ImGui::Selectable(choiceLabel.c_str(), sel))
329 {
330 if (c != opt.selectedIndex)
331 {
332 opt.selectedIndex = c;
333 model->cd.set(opt.optionID, opt.choiceIDs[c]);
334 model->refresh();
335 }
336 }
337 if (sel) ImGui::SetItemDefaultFocus();
338 }
339 ImGui::EndCombo();
340 }
341 }
342 }
343 }
344 ImGui::EndChild();
345
346 ImGui::SameLine();
347
348 // =================================================================
349 // CENTER COLUMN – Animation controls + 3D Viewport
350 // =================================================================
351 ImGui::BeginChild("##cvCenter",
352 ImVec2(centerW > 100.0f ? centerW : 100.0f, -1),
353 ImGuiChildFlags_None);
354 {
355 // ---- Animation Controls Bar ----
356 if (isChar && model->animated &&
357 ctx.animEntries && !ctx.animEntries->empty() && ctx.selectedAnimCombo)
358 {
359 auto& animEntries = *ctx.animEntries;
360 int& selectedAnimCombo = *ctx.selectedAnimCombo;
361
362 const char* animPreview =
363 (selectedAnimCombo >= 0 &&
364 selectedAnimCombo < static_cast<int>(animEntries.size()))
365 ? animEntries[selectedAnimCombo].label.c_str()
366 : "<none>";
367
368 ImGui::SetNextItemWidth(180);
369 if (ImGui::BeginCombo("##cvAnim", animPreview))
370 {
371 for (int i = 0; i < static_cast<int>(animEntries.size()); ++i)
372 {
373 bool selected = (i == selectedAnimCombo);
374 if (ImGui::Selectable(animEntries[i].label.c_str(), selected))
375 {
376 selectedAnimCombo = i;
377 int idx = animEntries[i].animIndex;
378 model->currentAnim = idx;
379 model->animManager->SetAnim(0, idx, 0);
380 model->animManager->Play();
381 }
382 if (selected) ImGui::SetItemDefaultFocus();
383 }
384 ImGui::EndCombo();
385 }
386
387 ImGui::SameLine();
388 if (ImGui::Button("\xE2\x97\x80##cv"))
389 model->animManager->PrevFrame();
390 ImGui::SameLine();
391 if (model->animManager->IsPaused())
392 {
393 if (ImGui::Button("\xE2\x96\xB6##cv"))
394 model->animManager->Play();
395 }
396 else
397 {
398 if (ImGui::Button("\xE2\x8F\xB8##cv"))
399 model->animManager->Pause();
400 }
401 ImGui::SameLine();
402 if (ImGui::Button("\xE2\x96\xB8##cv"))
403 model->animManager->NextFrame();
404
405 ImGui::SameLine();
406 ImGui::SetNextItemWidth(120);
407 int frameCount = static_cast<int>(model->animManager->GetFrameCount());
408 int curFrame = static_cast<int>(model->animManager->GetFrame());
409 if (frameCount > 0)
410 {
411 if (ImGui::SliderInt("##cvFrame", &curFrame, 0, frameCount))
412 model->animManager->SetFrame(static_cast<size_t>(curFrame));
413 }
414
415 ImGui::SameLine();
416 ImGui::Text("%d", curFrame);
417 }
418
419 // ---- 3D Viewport ----
420 ImVec2 vpSize = ImGui::GetContentRegionAvail();
421 int vpW = static_cast<int>(vpSize.x);
422 int vpH = static_cast<int>(vpSize.y);
423
424 if (vpW > 0 && vpH > 0 && ctx.fbo && ctx.camera && ctx.root && ctx.renderer)
425 {
426 ctx.renderer->renderScene(*ctx.fbo, vpW, vpH, *ctx.camera,
427 ctx.fov, ctx.bgColor, ctx.drawGrid,
428 [root = ctx.root]() {
429 glEnable(GL_LIGHTING);
430 glEnable(GL_TEXTURE_2D);
431 glEnable(GL_DEPTH_TEST);
432 glDepthFunc(GL_LEQUAL);
433 root->draw();
434 glEnable(GL_TEXTURE_2D);
435 glDisable(GL_LIGHTING);
436 glDepthMask(GL_FALSE);
437 glEnable(GL_BLEND);
438 root->drawParticles();
439 glDisable(GL_BLEND);
440 glDepthMask(GL_TRUE);
441 });
442 ImGui::Image(
443 static_cast<ImTextureID>(static_cast<uintptr_t>(ctx.fbo->colorTex)),
444 vpSize, ImVec2(0, 1), ImVec2(1, 0));
445
446 if (ImGui::IsItemHovered() && ctx.handleViewportInput)
448 }
449 }
450 ImGui::EndChild();
451
452 ImGui::SameLine();
453
454 // =================================================================
455 // RIGHT COLUMN – Equipment slots (placeholder)
456 // =================================================================
457 ImGui::BeginChild("##cvRight", ImVec2(s_rightWidth, -1), ImGuiChildFlags_Borders);
458 {
459 ImGui::SeparatorText("Equipment");
460
461 static const char* slotLabels[] = {
462 "Head", "Neck", "Shoulder", "Back", "Chest", "Shirt",
463 "Tabard", "Wrist", "Hands", "Waist", "Legs", "Feet",
464 "Main-hand", "Off-hand"
465 };
466
467 for (int i = 0; i < 14; ++i)
468 {
469 float w = ImGui::GetContentRegionAvail().x;
470 ImGui::PushID(i);
471 ImGui::BeginGroup();
472 ImGui::Text("%s:", slotLabels[i]);
473 ImGui::SameLine(w - ImGui::CalcTextSize("Empty").x);
474 ImGui::TextDisabled("Empty");
475 ImGui::EndGroup();
476 if (i < 13) ImGui::Separator();
477 ImGui::PopID();
478 }
479
480 ImGui::Spacing();
481 ImGui::Spacing();
482 if (ImGui::Button("Clear All Equipment", ImVec2(-1, 0)))
483 {
484 // TODO: clear all equipment when item loading is re-enabled
485 }
486
487 // ---- Export Section ----
488 ImGui::Spacing();
489 ImGui::Spacing();
490 ImGui::SeparatorText("Export");
491
492 if (isChar)
493 {
494 if (ImGui::Button("Export glTF", ImVec2(-1, 0)))
495 {
496 // TODO: quick-export from Character Viewer
497 ImGui::SetWindowFocus("Export");
498 }
499 }
500 else
501 {
502 ImGui::TextDisabled("Load a character first.");
503 }
504 }
505 ImGui::EndChild();
506}
507
508} // namespace CharacterViewerPanel
#define GAMEDIRECTORY
Definition Game.h:9
#define WOWDB
Definition WoWDatabase.h:65
size_t GetFrameCount()
void SetAnim(short index, unsigned int id, short loop)
void Pause(bool force=false)
bool IsPaused()
void SetFrame(size_t f)
size_t GetFrame()
Definition AnimManager.h:95
void set(uint chrCustomizationOptionID, uint chrCustomizationChoiceID)
Lightweight handle to a single row in a DB2Table.
Definition DB2Table.h:27
uint32_t getUInt(const std::string &field, unsigned int arrayIndex=0) const
Definition DB2Table.cpp:17
Abstract base class representing a file within the game data archive.
Definition GameFile.h:12
void renderScene(ViewportFBO &fbo, int w, int h, const OrbitCamera &camera, float fov, const glm::vec3 &clearColor, bool drawGrid, const std::function< void()> &drawObjects)
Definition Renderer.cpp:280
Core WoW .m2 model: geometry, animation, textures, and character data.
Definition WoWModel.h:50
size_t currentAnim
Definition WoWModel.h:183
bool animated
Definition WoWModel.h:172
void refresh()
CharDetails cd
Definition WoWModel.h:214
AnimManager * animManager
Definition WoWModel.h:180
void draw(const DrawContext &ctx)
Draw the Character Viewer panel contents (call between ImGui::Begin / End).
bool containsIgnoreCase(const std::string &s, const std::string &substr)
std::string toLower(const std::string &s)
Per-frame context passed by the caller so the panel never touches globals.
std::function< void()> handleViewportInput
std::vector< AnimEntry > * animEntries
std::function< WoWModel *()> getLoadedModel
std::function< void(GameFile *)> loadModel
std::vector< CustomizationOption > * customizationOptions
GLuint colorTex
Colour attachment (GL_RGBA8 texture).
Definition ViewportFBO.h:12