From 4f77b239e38f37abad768485dda73594457fd317 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 15:26:21 -0500 Subject: [PATCH] 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 --- dDashboardServer/routes/UgcRoutes.cpp | 70 +++++++- dDashboardServer/static/js/ugc-pose-math.js | 168 ++++++++++++++++++++ dUgcServer/UgcIconParams.cpp | 40 ++--- dUgcServer/UgcIconParams.h | 1 + tests/dUgcTests/CMakeLists.txt | 8 + tests/dUgcTests/UgcTests.cpp | 28 ++++ tests/dUgcTests/ugc-pose-fixture.json | 1 + tests/dUgcTests/ugc-pose-math.test.mjs | 86 ++++++++++ 8 files changed, 376 insertions(+), 26 deletions(-) create mode 100644 dDashboardServer/static/js/ugc-pose-math.js create mode 100644 tests/dUgcTests/ugc-pose-fixture.json create mode 100644 tests/dUgcTests/ugc-pose-math.test.mjs diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp index 3ab69b895..a0d60d9c8 100644 --- a/dDashboardServer/routes/UgcRoutes.cpp +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -1,5 +1,6 @@ #include "UgcRoutes.h" +#include #include #include @@ -89,7 +90,8 @@ namespace { } // Web thread: a car or rocket's kind, from its first module's build type (ModuleComponent) - std::optional ModularKind(const std::string& modules) { + std::optional ModularKind(std::string modules) { + std::replace(modules.begin(), modules.end(), '-', '+'); // a combination's key too const auto lots = UgcModularKey::Lots(modules); if (lots.empty()) return std::nullopt; auto query = CDClientDatabase::CreatePreppedStmt("SELECT m.buildType FROM ComponentsRegistry cr JOIN ModuleComponent m ON m.id = cr.component_id " @@ -100,6 +102,33 @@ namespace { return UgcIconParams::BuildKind(row.getIntField("buildType", 0)); } + // Web thread: each kind with something to edit its preset on: the newest made player model, the most used combination of each build type + nlohmann::json WithSamples(nlohmann::json kinds) { + std::map> best; // kind -> (combination, builds) + std::map uses; + for (const auto& [ldf, count] : Database::Get()->GetModularBuildConfigCounts()) uses[UgcModularKey::Normalize(ldf)] += count; + for (const auto& [key, count] : uses) { + if (key.empty()) continue; + std::string modules = key; + std::replace(modules.begin(), modules.end(), '-', '+'); + const auto kind = ModularKind(modules); + if (kind && count > best[*kind].second) best[*kind] = { key, count }; + } + for (auto& entry : kinds) { + const auto kind = entry.value("kind", std::string()); + if (kind == UgcIconParams::ModelKind()) { + const auto models = Database::Get()->GetUgcProcessList(IUgc::eProcessState::DONE, "", 0, 1); + entry["sample"] = models.empty() ? nlohmann::json(nullptr) : nlohmann::json(std::to_string(models.front().id)); + } else if (const auto it = best.find(kind); it != best.end()) { + entry["sample"] = it->second.first; + entry["sampleBuilds"] = it->second.second; + } else { + entry["sample"] = nullptr; + } + } + return kinds; + } + // Web thread: the values stored for a target, or null nlohmann::json StoredValues(const std::string& target) { const auto stored = Database::Get()->GetUgcIconSettings(target); @@ -293,15 +322,16 @@ namespace UgcRoutes { }); Route(eHTTPMethod::GET, "/api/ugc/icon/params", Perm("properties_view"), - "What an icon's framing and light can be set to: {params: [{key, setting, label, unit, min, max, step, default, description}], kinds: [{kind, label}]} " - "(player models, and each car or rocket build type in the client's data)", + "What an icon's framing and light can be set to: {params: [{key, group, setting, label, unit, min, max, step, default, description}], kinds: [{kind, " + "label, buildType, sample}]} (player models, and each car or rocket build type in the client's data; sample: a model id or the most used module " + "combination of that kind, to edit its preset on)", [](HTTPReply& reply, const HTTPContext&) { nlohmann::json params = nlohmann::json::array(); for (const auto& param : UgcIconParams::List()) { - params.push_back({ { "key", param.key }, { "setting", param.setting }, { "label", param.label }, { "unit", param.unit }, { "min", param.min }, + params.push_back({ { "key", param.key }, { "group", param.group }, { "setting", param.setting }, { "label", param.label }, { "unit", param.unit }, { "min", param.min }, { "max", param.max }, { "step", param.step }, { "default", param.defaultValue }, { "description", param.description } }); } - JsonSuccess(reply, { { "params", params }, { "kinds", IconKinds() } }); + JsonSuccess(reply, { { "params", params }, { "kinds", WithSamples(IconKinds()) } }); }); Route(eHTTPMethod::GET, "/api/ugc/icon/settings", Perm("properties_view"), @@ -332,7 +362,35 @@ namespace UgcRoutes { { "own", target.empty() ? nlohmann::json(nullptr) : StoredValues(target) } }); }); - Route(eHTTPMethod::POST, "/api/ugc/icon/preview", Perm("ugc_manage"), + Route(eHTTPMethod::GET, "/api/ugc/assembly", Perm("properties_view"), + "A car or rocket's modules put together as the icon renderer does (turned by its build type's AdditionalModelRotation), converted for the 3D " + "view (NifFile::Encode). Made by the UGC server on a worker and cached per combination. Query: ?modules=4713-4714-4715 (or an ldf_config)", + [](HTTPReply& reply, const HTTPContext& context) { + auto asked = QueryValue(context.queryString, "modules"); + std::replace(asked.begin(), asked.end(), '-', '+'); + const auto key = UgcModularKey::Normalize(asked); + if (key.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No modules"); + const auto url = InternalUrl() + "/admin/assembly"; + Workers::Reply(reply, context, false, [url, key, admin = AdminKey()](HTTPReply& out) { + std::string modules = key; + std::replace(modules.begin(), modules.end(), '-', '+'); + const auto fetched = AdminPost(url, admin, nlohmann::json{ { "modules", modules } }.dump()); + if (fetched->status == 0) return JsonError(out, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer at " + url + " (" + fetched->error + ")"); + if (fetched->status != 200) { + const auto error = nlohmann::json::parse(fetched->body, nullptr, false); + return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, error.is_object() ? error.value("error", fetched->body) : fetched->body); + } + std::string error; + const auto model = NifFile::Parse(fetched->body, 0, error); + if (!model) return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, "The .nif can't be read: " + error); + out.status = eHTTPStatusCode::OK; + out.contentType = eContentType::APPLICATION_OCTET_STREAM; + out.message = NifFile::Encode(*model, std::vector(model->meshes.size())); + out.headers.push_back("Cache-Control: private, max-age=60"); + }); + }); + + Route(eHTTPMethod::POST, "/api/ugc/icon/preview", Perm("properties_view"), "An icon drawn by the UGC server with the given values, not stored (PNG). Body: {kind: model, id, values} or {kind: modular, modules, values}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); diff --git a/dDashboardServer/static/js/ugc-pose-math.js b/dDashboardServer/static/js/ugc-pose-math.js new file mode 100644 index 000000000..388822ea1 --- /dev/null +++ b/dDashboardServer/static/js/ugc-pose-math.js @@ -0,0 +1,168 @@ +/** + * The icon pose's math, as the UGC server works it out (dUgcServer/UgcIconPose.cpp), without three.js so node can test + * it (tests/dWebTests/ugc-pose-math.test.mjs checks both against the same fixture). Angles are degrees; matrices are + * column-major arrays of 16, as glm and three.js keep them. + * + * The model is turned first (modelRotation, about its origin), then the camera looks at the centre of its bounds from + * cameraDirection, as far away as makes the bounding sphere fill the field of view, and last the picture is cropped to + * the model's projected bounds: scaled so the larger side fills the icon less the margin, then shifted. + */ + +const RAD = Math.PI / 180; + +// From the model towards the camera: yaw around +Y from +Z towards +X, pitch up from the ground +export function cameraDirection(yaw, pitch) { + const y = yaw * RAD, p = pitch * RAD; + return [Math.sin(y) * Math.cos(p), Math.sin(p), Math.cos(y) * Math.cos(p)]; +} + +// The inverse: [yaw, pitch] of a direction +export function directionAngles(d) { + return [Math.atan2(d[0], d[2]) / RAD, Math.atan2(d[1], Math.hypot(d[0], d[2])) / RAD]; +} + +export function multiply(a, b) { + const out = new Array(16).fill(0); + for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { + let sum = 0; + for (let k = 0; k < 4; k++) sum += a[k * 4 + r] * b[c * 4 + k]; + out[c * 4 + r] = sum; + } + return out; +} + +function rotation(axis, degrees) { + const c = Math.cos(degrees * RAD), s = Math.sin(degrees * RAD); + if (axis === 'y') return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; + if (axis === 'x') return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]; + return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +} + +// The model's turn: R = Ry(yaw) * Rx(pitch) * Rz(roll) (three.js's Euler order 'YXZ') +export function modelRotation(yaw, pitch, roll) { + return multiply(multiply(rotation('y', yaw), rotation('x', pitch)), rotation('z', roll)); +} + +// The inverse: [yaw, pitch, roll] +export function rotationAngles(m) { + const m13 = m[8], m23 = m[9], m33 = m[10], m21 = m[1], m22 = m[5], m11 = m[0], m31 = m[2]; + const pitch = Math.asin(Math.max(-1, Math.min(1, -m23))); + if (Math.abs(m23) < 0.9999999) return [Math.atan2(m13, m33) / RAD, pitch / RAD, Math.atan2(m21, m22) / RAD]; + return [Math.atan2(-m31, m11) / RAD, pitch / RAD, 0]; +} + +export function transformPoint(m, p) { + return [m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]]; +} + +// glm::perspective (right handed, depth -1..1) +export function perspective(fov, aspect, near, far) { + const f = 1 / Math.tan(fov / 2); + return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, -(far + near) / (far - near), -1, 0, 0, -(2 * far * near) / (far - near), 0]; +} + +// glm::lookAt (right handed) +export function lookAt(eye, center, up) { + const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; }; + const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const f = norm(sub(center, eye)), s = norm(cross(f, up)), u = cross(s, f); + return [s[0], u[0], -f[0], 0, s[1], u[1], -f[1], 0, s[2], u[2], -f[2], 0, -dot(s, eye), -dot(u, eye), dot(f, eye), 1]; +} + +function clip(m, p) { + return [m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], + m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]]; +} + +/** + * The frame of a model: `positions` a flat array (x, y, z, ...) of its vertices before the turn, `rotation` the turn + * (modelRotation), `camera` {yaw, pitch, fov, margin, offsetX, offsetY}. {ok, center, radius, eye, fov (radians), + * distance, near, far, view, projection, centerX, centerY, scale, offset} + */ +export function computeFrame(positions, rotation, camera) { + const r = rotation; + let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + const n = positions.length / 3; + const turned = new Float32Array(positions.length); + for (let i = 0; i < n; i++) { + const x = positions[i * 3], y = positions[i * 3 + 1], z = positions[i * 3 + 2]; + const tx = r[0] * x + r[4] * y + r[8] * z + r[12], ty = r[1] * x + r[5] * y + r[9] * z + r[13], tz = r[2] * x + r[6] * y + r[10] * z + r[14]; + turned[i * 3] = tx; turned[i * 3 + 1] = ty; turned[i * 3 + 2] = tz; + if (tx < minX) minX = tx; if (tx > maxX) maxX = tx; + if (ty < minY) minY = ty; if (ty > maxY) maxY = ty; + if (tz < minZ) minZ = tz; if (tz > maxZ) maxZ = tz; + } + const frame = { ok: false }; + if (n === 0) { + frame.center = [0, 0, 0]; + frame.radius = 1; + } else { + frame.center = [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2]; + frame.radius = Math.max(Math.hypot(maxX - minX, maxY - minY, maxZ - minZ) / 2, 0.01); + } + const dir = cameraDirection(camera.yaw, camera.pitch); + frame.fov = Math.max(1, Math.min(120, camera.fov)) * RAD; + frame.distance = frame.radius / Math.sin(frame.fov / 2); + frame.eye = [0, 1, 2].map((k) => frame.center[k] + dir[k] * frame.distance); + frame.near = Math.max(frame.distance - frame.radius * 1.5, frame.distance * 0.01); + frame.far = frame.distance + frame.radius * 1.5; + frame.view = lookAt(frame.eye, frame.center, [0, 1, 0]); + frame.projection = perspective(frame.fov, 1, frame.near, frame.far); + frame.viewProjection = multiply(frame.projection, frame.view); + let pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity; + const vp = frame.viewProjection; + for (let i = 0; i < n; i++) { + const c = clip(vp, [turned[i * 3], turned[i * 3 + 1], turned[i * 3 + 2]]); + if (c[3] <= 0) continue; + const x = c[0] / c[3], y = c[1] / c[3]; + if (x < pMinX) pMinX = x; if (x > pMaxX) pMaxX = x; + if (y < pMinY) pMinY = y; if (y > pMaxY) pMaxY = y; + } + if (pMinX > pMaxX) return frame; + frame.centerX = (pMinX + pMaxX) / 2; + frame.centerY = (pMinY + pMaxY) / 2; + frame.scale = 2 / (Math.max(pMaxX - pMinX, pMaxY - pMinY, 1e-6) * Math.max(camera.margin, 0.1)); + frame.offset = [camera.offsetX || 0, camera.offsetY || 0]; + frame.ok = true; + return frame; +} + +// A (turned) point's place in the icon: [x right, y down] 0..1 across it, and its depth +export function iconPoint(frame, p) { + const c = clip(frame.viewProjection, p); + const w = c[3] > 1e-6 ? c[3] : 1e-6; + return [0.5 + frame.offset[0] + (c[0] / w - frame.centerX) * frame.scale * 0.5, 0.5 - frame.offset[1] - (c[1] / w - frame.centerY) * frame.scale * 0.5, c[2] / w]; +} + +// The icon's square in the camera's NDC: [minX, minY, maxX, maxY] +export function iconRect(frame) { + const half = 2 / frame.scale; + return [frame.centerX + (-0.5 - frame.offset[0]) * half, frame.centerY + (-0.5 - frame.offset[1]) * half, + frame.centerX + (0.5 - frame.offset[0]) * half, frame.centerY + (0.5 - frame.offset[1]) * half]; +} + +/** + * The projection that shows the icon's square (grown by `context` around its centre, so what is just outside shows) + * on a view of aspect `aspect` (width over height): the frame's projection with the crop in front, column-major. + * The icon's square is then the centred square of min(width, height) / context pixels. + */ +export function viewProjection(frame, aspect, context) { + const rect = iconRect(frame); + const qx = (rect[0] + rect[2]) / 2, qy = (rect[1] + rect[3]) / 2, half = (rect[2] - rect[0]) / 2 * context; + const hx = aspect >= 1 ? half * aspect : half, hy = aspect >= 1 ? half : half / aspect; + // In NDC x' = (x - qx) / hx; in clip space (homogeneous) x' = (x - qx w) / hx, row by row of the projection + const p = frame.projection; + const out = p.slice(); + for (let c = 0; c < 4; c++) { + out[c * 4] = (p[c * 4] - qx * p[c * 4 + 3]) / hx; + out[c * 4 + 1] = (p[c * 4 + 1] - qy * p[c * 4 + 3]) / hy; + } + return out; +} + +// Keeps an angle in -180..180 +export function wrapDegrees(a) { + return ((((a + 180) % 360) + 360) % 360) - 180; +} diff --git a/dUgcServer/UgcIconParams.cpp b/dUgcServer/UgcIconParams.cpp index 6bfc83181..6cc558516 100644 --- a/dUgcServer/UgcIconParams.cpp +++ b/dUgcServer/UgcIconParams.cpp @@ -15,26 +15,26 @@ namespace UgcIconParams { // Framing from LU Toolbox's icon renderer (its UGC render add-on's BrickBuild scene); the light matched to the // brightness of the game's own model icons (res/textures/ui/inventory/models), docs/UgcServer.md static const std::vector list = { - { "yaw", "icon_yaw", "Angle around", "degrees", -180, 180, 1, 53.36f, "The camera's angle around the model, from its front.", [](Options& o, float v) { o.yawDegrees = v; } }, - { "pitch", "icon_pitch", "Angle above", "degrees", -89, 89, 1, 19.54f, "", [](Options& o, float v) { o.pitchDegrees = v; } }, - { "fov", "icon_fov", "Field of view", "degrees", 5, 90, 0.5f, 39.6f, "", [](Options& o, float v) { o.fovDegrees = v; } }, - { "margin", "icon_margin", "Border (zoom)", "", 0.5f, 3, 0.01f, 1.03f, "1 fills the icon, more leaves a border.", [](Options& o, float v) { o.margin = v; } }, - { "offsetX", "icon_offset_x", "Shift right", "", -0.5f, 0.5f, 0.01f, 0.0f, "Share of the icon's width.", [](Options& o, float v) { o.offsetX = v; } }, - { "offsetY", "icon_offset_y", "Shift up", "", -0.5f, 0.5f, 0.01f, 0.0f, "Share of the icon's height.", [](Options& o, float v) { o.offsetY = v; } }, - { "modelYaw", "icon_model_yaw", "Model turned around", "degrees", -180, 180, 1, 0.0f, "The model turned around its up axis before the camera looks at it.", [](Options& o, float v) { o.modelYawDegrees = v; } }, - { "modelPitch", "icon_model_pitch", "Model tipped forward", "degrees", -90, 90, 1, 0.0f, "Turned around its side axis, after the turn around.", [](Options& o, float v) { o.modelPitchDegrees = v; } }, - { "modelRoll", "icon_model_roll", "Model rolled", "degrees", -180, 180, 1, 0.0f, "Turned around its front axis, last.", [](Options& o, float v) { o.modelRollDegrees = v; } }, - { "sunYaw", "icon_sun_yaw", "Sun around", "degrees", -180, 180, 1, 21.0f, "", [](Options& o, float v) { o.sunYawDegrees = v; } }, - { "sunPitch", "icon_sun_pitch", "Sun above", "degrees", -10, 90, 1, 50.3f, "", [](Options& o, float v) { o.sunPitchDegrees = v; } }, - { "sunStrength", "icon_sun_light", "Sun strength", "", 0, 10, 0.05f, 2.0f, "", [](Options& o, float v) { o.sunStrength = v; } }, - { "ambient", "icon_world_light", "World light", "", 0, 2, 0.01f, 1.0f, "What every face gets.", [](Options& o, float v) { o.ambient = v; } }, - { "fill", "icon_fill", "Fill light", "", 0, 5, 0.05f, 0.8f, "A light from the camera.", [](Options& o, float v) { o.fill = v; } }, - { "specular", "icon_specular", "Highlights", "", 0, 2, 0.05f, 0.2f, "The sun's highlight on the plastic.", [](Options& o, float v) { o.specular = v; } }, - { "shininess", "icon_shininess", "Highlight tightness", "", 1, 200, 1, 40.0f, "", [](Options& o, float v) { o.shininess = v; } }, - { "exposure", "icon_exposure", "Exposure", "", 0.1f, 4, 0.05f, 2.6f, "Brightness of everything.", [](Options& o, float v) { o.exposure = v; } }, - { "contrast", "icon_contrast", "Contrast", "", 0.5f, 1.5f, 0.01f, 1.0f, "", [](Options& o, float v) { o.contrast = v; } }, - { "shadows", "icon_shadow_strength", "Shadows", "", 0, 1, 0.05f, 0.4f, "How much the sun's shadows darken.", [](Options& o, float v) { o.shadows = v; } }, - { "aoStrength", "icon_ao_strength", "Ambient occlusion", "", 0, 1, 0.05f, 0.0f, + { "yaw", "camera", "icon_yaw", "Angle around", "degrees", -180, 180, 1, 53.36f, "The camera's angle around the model, from its front.", [](Options& o, float v) { o.yawDegrees = v; } }, + { "pitch", "camera", "icon_pitch", "Angle above", "degrees", -89, 89, 1, 19.54f, "", [](Options& o, float v) { o.pitchDegrees = v; } }, + { "fov", "camera", "icon_fov", "Field of view", "degrees", 5, 90, 0.5f, 39.6f, "", [](Options& o, float v) { o.fovDegrees = v; } }, + { "margin", "framing", "icon_margin", "Border (zoom)", "", 0.5f, 3, 0.01f, 1.03f, "1 fills the icon, more leaves a border.", [](Options& o, float v) { o.margin = v; } }, + { "offsetX", "framing", "icon_offset_x", "Shift right", "", -0.5f, 0.5f, 0.01f, 0.0f, "Share of the icon's width.", [](Options& o, float v) { o.offsetX = v; } }, + { "offsetY", "framing", "icon_offset_y", "Shift up", "", -0.5f, 0.5f, 0.01f, 0.0f, "Share of the icon's height.", [](Options& o, float v) { o.offsetY = v; } }, + { "modelYaw", "model", "icon_model_yaw", "Model turned around", "degrees", -180, 180, 1, 0.0f, "The model turned around its up axis before the camera looks at it.", [](Options& o, float v) { o.modelYawDegrees = v; } }, + { "modelPitch", "model", "icon_model_pitch", "Model tipped forward", "degrees", -90, 90, 1, 0.0f, "Turned around its side axis, after the turn around.", [](Options& o, float v) { o.modelPitchDegrees = v; } }, + { "modelRoll", "model", "icon_model_roll", "Model rolled", "degrees", -180, 180, 1, 0.0f, "Turned around its front axis, last.", [](Options& o, float v) { o.modelRollDegrees = v; } }, + { "sunYaw", "sun", "icon_sun_yaw", "Sun around", "degrees", -180, 180, 1, 21.0f, "", [](Options& o, float v) { o.sunYawDegrees = v; } }, + { "sunPitch", "sun", "icon_sun_pitch", "Sun above", "degrees", -10, 90, 1, 50.3f, "", [](Options& o, float v) { o.sunPitchDegrees = v; } }, + { "sunStrength", "sun", "icon_sun_light", "Sun strength", "", 0, 10, 0.05f, 2.0f, "", [](Options& o, float v) { o.sunStrength = v; } }, + { "ambient", "light", "icon_world_light", "World light", "", 0, 2, 0.01f, 1.0f, "What every face gets.", [](Options& o, float v) { o.ambient = v; } }, + { "fill", "light", "icon_fill", "Fill light", "", 0, 5, 0.05f, 0.8f, "A light from the camera.", [](Options& o, float v) { o.fill = v; } }, + { "specular", "light", "icon_specular", "Highlights", "", 0, 2, 0.05f, 0.2f, "The sun's highlight on the plastic.", [](Options& o, float v) { o.specular = v; } }, + { "shininess", "light", "icon_shininess", "Highlight tightness", "", 1, 200, 1, 40.0f, "", [](Options& o, float v) { o.shininess = v; } }, + { "exposure", "look", "icon_exposure", "Exposure", "", 0.1f, 4, 0.05f, 2.6f, "Brightness of everything.", [](Options& o, float v) { o.exposure = v; } }, + { "contrast", "look", "icon_contrast", "Contrast", "", 0.5f, 1.5f, 0.01f, 1.0f, "", [](Options& o, float v) { o.contrast = v; } }, + { "shadows", "sun", "icon_shadow_strength", "Shadows", "", 0, 1, 0.05f, 0.4f, "How much the sun's shadows darken.", [](Options& o, float v) { o.shadows = v; } }, + { "aoStrength", "look", "icon_ao_strength", "Ambient occlusion", "", 0, 1, 0.05f, 0.0f, "Occlusion worked out for the icon. Player models' meshes have theirs baked in already.", [](Options& o, float v) { o.ao.strength = v; o.ao.enabled = v > 0.0f; } }, }; return list; diff --git a/dUgcServer/UgcIconParams.h b/dUgcServer/UgcIconParams.h index ba8a06e7e..c5ac40af6 100644 --- a/dUgcServer/UgcIconParams.h +++ b/dUgcServer/UgcIconParams.h @@ -20,6 +20,7 @@ namespace UgcIconParams { struct Param { std::string key; // in presets and overrides, e.g. "sunStrength" + std::string group; // what it is part of, for the editor: camera, framing, model, sun, light, look std::string setting; // ugcconfig.ini, e.g. "icon_sun_strength" std::string label; std::string unit; diff --git a/tests/dUgcTests/CMakeLists.txt b/tests/dUgcTests/CMakeLists.txt index 69c56d81b..d5e062e2a 100644 --- a/tests/dUgcTests/CMakeLists.txt +++ b/tests/dUgcTests/CMakeLists.txt @@ -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() diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index f7642b96d..4fce61622 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -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 positions; + const auto& flat = fixture["positions"]; + for (size_t i = 0; i + 2 < flat.size(); i += 3) positions.emplace_back(flat[i].get(), flat[i + 1].get(), flat[i + 2].get()); + for (const auto& c : fixture["cases"]) { + const auto& pose = c["pose"]; + const auto rotation = UgcIconPose::ModelRotation(pose["modelYaw"].get(), pose["modelPitch"].get(), pose["modelRoll"].get()); + std::vector turned; + for (const auto& p : positions) turned.emplace_back(rotation * glm::vec4(p, 1.0f)); + const auto frame = UgcIconPose::Compute({ &turned }, { pose["yaw"].get(), pose["pitch"].get(), pose["fov"].get(), + pose["margin"].get(), pose["offsetX"].get(), pose["offsetY"].get() }); + ASSERT_TRUE(frame.ok); + for (int k = 0; k < 3; k++) EXPECT_NEAR(frame.center[k], c["center"][k].get(), 1e-4f); + for (int k = 0; k < 3; k++) EXPECT_NEAR(frame.eye[k], c["eye"][k].get(), 1e-3f); + EXPECT_NEAR(frame.scale, c["scale"].get(), 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(), 1e-4f) << v; + EXPECT_NEAR(point.y, c["iconPoints"][v][1].get(), 1e-4f) << v; + } + } +} diff --git a/tests/dUgcTests/ugc-pose-fixture.json b/tests/dUgcTests/ugc-pose-fixture.json new file mode 100644 index 000000000..3e96b3de7 --- /dev/null +++ b/tests/dUgcTests/ugc-pose-fixture.json @@ -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]]}]} diff --git a/tests/dUgcTests/ugc-pose-math.test.mjs b/tests/dUgcTests/ugc-pose-math.test.mjs new file mode 100644 index 000000000..54d6793dc --- /dev/null +++ b/tests/dUgcTests/ugc-pose-math.test.mjs @@ -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 +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');