feat(dashboard): assembled mesh route, icon parameter groups and preset samples, pose math in JS

GET /api/ugc/assembly converts the UGC server's assembled .nif for the 3D
view. The icon parameter list names each parameter's group; the kinds carry a
sample (a model, or the most used combination) to edit a preset on. The icon
preview is viewable as the /ugc page. ugc-pose-math.js mirrors UgcIconPose and
both are checked against one fixture (gtest and node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 15:26:21 -05:00
parent 64225e61af
commit eca4c6895c
8 changed files with 376 additions and 26 deletions

View File

@@ -2,3 +2,11 @@ add_executable(dUgcTests "UgcTests.cpp")
target_include_directories(dUgcTests PRIVATE "${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables")
target_link_libraries(dUgcTests ${COMMON_LIBRARIES} dUgc GTest::gtest_main)
gtest_discover_tests(dUgcTests)
target_compile_definitions(dUgcTests PRIVATE UGC_POSE_FIXTURE="${CMAKE_CURRENT_SOURCE_DIR}/ugc-pose-fixture.json")
# The dashboard's pose editor math (static/js/ugc-pose-math.js) against the same fixture, when node is there
find_program(NODE_EXECUTABLE node)
if(NODE_EXECUTABLE)
add_test(NAME UgcPoseMathJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/ugc-pose-math.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/ugc-pose-math.js" "${CMAKE_CURRENT_SOURCE_DIR}/ugc-pose-fixture.json")
endif()

View File

@@ -23,6 +23,7 @@
#include "UgcStorage.h"
#include "UgcThrottle.h"
#include "ZCompression.h"
#include "json.hpp"
class Logger;
class dConfig;
@@ -896,3 +897,30 @@ TEST(UgcJobs, AssemblyNifIsTheIconsModel) {
input.modules.clear();
EXPECT_FALSE(UgcJobs::AssemblyNif(input, res, error));
}
TEST(UgcIconPose, MatchesTheEditorsFixture) {
// The same numbers the dashboard's editor math (ugc-pose-math.js) is checked against
std::ifstream file(UGC_POSE_FIXTURE);
const auto fixture = nlohmann::json::parse(file, nullptr, false);
ASSERT_TRUE(fixture.is_object());
std::vector<glm::vec3> positions;
const auto& flat = fixture["positions"];
for (size_t i = 0; i + 2 < flat.size(); i += 3) positions.emplace_back(flat[i].get<float>(), flat[i + 1].get<float>(), flat[i + 2].get<float>());
for (const auto& c : fixture["cases"]) {
const auto& pose = c["pose"];
const auto rotation = UgcIconPose::ModelRotation(pose["modelYaw"].get<float>(), pose["modelPitch"].get<float>(), pose["modelRoll"].get<float>());
std::vector<glm::vec3> turned;
for (const auto& p : positions) turned.emplace_back(rotation * glm::vec4(p, 1.0f));
const auto frame = UgcIconPose::Compute({ &turned }, { pose["yaw"].get<float>(), pose["pitch"].get<float>(), pose["fov"].get<float>(),
pose["margin"].get<float>(), pose["offsetX"].get<float>(), pose["offsetY"].get<float>() });
ASSERT_TRUE(frame.ok);
for (int k = 0; k < 3; k++) EXPECT_NEAR(frame.center[k], c["center"][k].get<float>(), 1e-4f);
for (int k = 0; k < 3; k++) EXPECT_NEAR(frame.eye[k], c["eye"][k].get<float>(), 1e-3f);
EXPECT_NEAR(frame.scale, c["scale"].get<float>(), 1e-4f);
for (size_t v = 0; v < turned.size(); v++) {
const auto point = frame.IconPoint(turned[v]);
EXPECT_NEAR(point.x, c["iconPoints"][v][0].get<float>(), 1e-4f) << v;
EXPECT_NEAR(point.y, c["iconPoints"][v][1].get<float>(), 1e-4f) << v;
}
}
}

View File

@@ -0,0 +1 @@
{"positions":[-1,0,-2,3,0,-2,-1,2,-2,3,2,-2,-1,0,1,3,0,1,-1,2,1,3,2,1,0.5,3,0],"cases":[{"pose":{"yaw":53.36,"pitch":19.54,"fov":39.6,"margin":1.03,"offsetX":0,"offsetY":0,"modelYaw":0,"modelPitch":0,"modelRoll":0},"center":[1,1.5,-0.5],"radius":2.915476,"eye":[7.508429,4.378698,4.34064],"centerX":0.062911,"centerY":-0.21753,"scale":1.222873,"iconPoints":[[0.46302,0.452537],[0.943913,0.699213],[0.463112,0.161325],[0.985437,0.315884],[0.045143,0.571313],[0.459011,0.93283],[0.014563,0.23488],[0.458734,0.468875],[0.316675,0.06717]]},{"pose":{"yaw":-120,"pitch":45,"fov":20,"margin":1.5,"offsetX":0.1,"offsetY":-0.2,"modelYaw":30,"modelPitch":-20,"modelRoll":75},"center":[-1.056739,0.979874,-0.416113],"radius":3.944206,"eye":[-14.966032,17.040941,-8.446646],"centerX":-0.036116,"centerY":0.012075,"scale":1.06264,"iconPoints":[[0.530059,0.96253],[0.296888,0.697939],[0.731924,1.033333],[0.515782,0.764409],[0.70155,0.660225],[0.50203,0.366667],[0.903112,0.713883],[0.721579,0.4122],[0.893538,0.747872]]},{"pose":{"yaw":170,"pitch":-30,"fov":80,"margin":0.8,"offsetX":-0.3,"offsetY":0.25,"modelYaw":-90,"modelPitch":60,"modelRoll":-150},"center":[1.433013,-0.25,-0.366025],"radius":3.944893,"eye":[2.355942,-3.318582,-5.600219],"centerX":-0.085318,"centerY":0.088845,"scale":2.619823,"iconPoints":[[0.430018,0.134802],[0.157851,-0.375],[0.128677,0.341233],[-0.360557,0.00766],[0.760557,0.605839],[0.745053,0.425891],[0.407899,0.829488],[0.052023,0.875],[0.012929,0.751559]]}]}

View File

@@ -0,0 +1,86 @@
// The dashboard's pose editor math against the fixture the UGC server's (UgcIconPose) is checked against too, so the
// editor's 3D view and the icons agree. Run by ctest: node ugc-pose-math.test.mjs <ugc-pose-math.js> <fixture.json>
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const [modulePath, fixturePath] = process.argv.slice(2);
const P = await import(pathToFileURL(modulePath).href);
const fixture = JSON.parse(readFileSync(fixturePath, 'utf8'));
let failures = 0;
const near = (a, b, tolerance, what) => {
if (!(Math.abs(a - b) <= tolerance)) {
failures++;
console.error(`${what}: ${a} is not ${b}`);
}
};
// Round trips
for (const yaw of [-170, -53, 0, 21, 90, 179]) for (const pitch of [-80, -10, 0, 19.54, 60]) {
const [y, p] = P.directionAngles(P.cameraDirection(yaw, pitch).map((v) => v * 3));
near(y, yaw, 1e-6, `camera yaw ${yaw}`);
near(p, pitch, 1e-6, `camera pitch ${pitch}`);
}
for (const [yaw, pitch, roll] of [[0, 0, 0], [30, 20, 10], [-120, -45, 170], [90, 89, -90], [179, 0, -179]]) {
const [y, p, r] = P.rotationAngles(P.modelRotation(yaw, pitch, roll));
near(y, yaw, 1e-4, 'model yaw');
near(p, pitch, 1e-4, 'model pitch');
near(r, roll, 1e-4, 'model roll');
}
near(P.wrapDegrees(190), -170, 1e-9, 'wrap');
near(P.wrapDegrees(-181), 179, 1e-9, 'wrap');
// The fixture (the C++ test checks the same numbers)
for (const [i, c] of fixture.cases.entries()) {
const rotation = P.modelRotation(c.pose.modelYaw, c.pose.modelPitch, c.pose.modelRoll);
const frame = P.computeFrame(fixture.positions, rotation, c.pose);
c.center.forEach((v, k) => near(frame.center[k], v, 1e-5, `case ${i} center`));
c.eye.forEach((v, k) => near(frame.eye[k], v, 1e-4, `case ${i} eye`));
near(frame.scale, c.scale, 1e-5, `case ${i} scale`);
c.iconPoints.forEach((point, v) => {
const got = P.iconPoint(frame, P.transformPoint(rotation, fixture.positions.slice(v * 3, v * 3 + 3)));
near(got[0], point[0], 1e-5, `case ${i} vertex ${v} x`);
near(got[1], point[1], 1e-5, `case ${i} vertex ${v} y`);
});
// The editor's view: the icon's square is the centred square of 1 / context of the view
for (const aspect of [1, 1.6, 0.7]) {
const vp = P.multiply(P.viewProjection(frame, aspect, 1.4), frame.view);
const rect = P.iconRect(frame);
// A world point on the icon's top left corner (NDC of the frame's own camera, through its inverse)
const inv = invert(frame.viewProjection);
const h = [0, 1, 2, 3].map((r) => inv[r] * rect[0] + inv[4 + r] * rect[3] + inv[8 + r] * 0.5 + inv[12 + r]);
const corner = [h[0] / h[3], h[1] / h[3], h[2] / h[3]];
const c4 = [0, 1, 2, 3].map((r) => vp[r] * corner[0] + vp[4 + r] * corner[1] + vp[8 + r] * corner[2] + vp[12 + r]);
// Lands on the view's centred square of 1 / 1.4 of its shorter side
near(c4[0] / c4[3], aspect >= 1 ? -1 / (1.4 * aspect) : -1 / 1.4, 1e-4, `case ${i} view corner x (${aspect})`);
near(c4[1] / c4[3], aspect >= 1 ? 1 / 1.4 : aspect / 1.4, 1e-4, `case ${i} view corner y (${aspect})`);
}
}
// A general 4x4 inverse (for the check above), with the perspective divide
function invert(m) {
const inv = new Array(16);
inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15] + m[9] * m[7] * m[14] + m[13] * m[6] * m[11] - m[13] * m[7] * m[10];
inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15] - m[8] * m[7] * m[14] - m[12] * m[6] * m[11] + m[12] * m[7] * m[10];
inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15] + m[8] * m[7] * m[13] + m[12] * m[5] * m[11] - m[12] * m[7] * m[9];
inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14] - m[8] * m[6] * m[13] - m[12] * m[5] * m[10] + m[12] * m[6] * m[9];
inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15] - m[9] * m[3] * m[14] - m[13] * m[2] * m[11] + m[13] * m[3] * m[10];
inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15] + m[8] * m[3] * m[14] + m[12] * m[2] * m[11] - m[12] * m[3] * m[10];
inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15] - m[8] * m[3] * m[13] - m[12] * m[1] * m[11] + m[12] * m[3] * m[9];
inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14] + m[8] * m[2] * m[13] + m[12] * m[1] * m[10] - m[12] * m[2] * m[9];
inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15] + m[5] * m[3] * m[14] + m[13] * m[2] * m[7] - m[13] * m[3] * m[6];
inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15] - m[4] * m[3] * m[14] - m[12] * m[2] * m[7] + m[12] * m[3] * m[6];
inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15] + m[4] * m[3] * m[13] + m[12] * m[1] * m[7] - m[12] * m[3] * m[5];
inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14] - m[4] * m[2] * m[13] - m[12] * m[1] * m[6] + m[12] * m[2] * m[5];
inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11] - m[5] * m[3] * m[10] - m[9] * m[2] * m[7] + m[9] * m[3] * m[6];
inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11] + m[4] * m[3] * m[10] + m[8] * m[2] * m[7] - m[8] * m[3] * m[6];
inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11] - m[4] * m[3] * m[9] - m[8] * m[1] * m[7] + m[8] * m[3] * m[5];
inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10] + m[4] * m[2] * m[9] + m[8] * m[1] * m[6] - m[8] * m[2] * m[5];
const det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];
return inv.map((v) => v / det);
}
if (failures) {
console.error(`${failures} check(s) failed`);
process.exit(1);
}
console.log('ugc-pose-math: all checks passed');