mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-24 21:52:48 +00:00
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:
@@ -1,5 +1,6 @@
|
||||
#include "UgcRoutes.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
|
||||
@@ -89,7 +90,8 @@ namespace {
|
||||
}
|
||||
|
||||
// Web thread: a car or rocket's kind, from its first module's build type (ModuleComponent)
|
||||
std::optional<std::string> ModularKind(const std::string& modules) {
|
||||
std::optional<std::string> 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<std::string, std::pair<std::string, uint64_t>> best; // kind -> (combination, builds)
|
||||
std::map<std::string, uint64_t> 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<std::string>(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);
|
||||
|
||||
168
dDashboardServer/static/js/ugc-pose-math.js
Normal file
168
dDashboardServer/static/js/ugc-pose-math.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user