mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(ugc): icon pose math shared with the editor, model rotation parameters, assembled mesh endpoint
UgcIconPose holds the icon camera, model rotation (yaw/pitch/roll, YXZ) and the crop to the projected bounds; RenderIcon uses it. The parameter list gains the model's turn (defaults 0, so icons stay the same). POST /admin/assembly returns a module combination's assembled .nif (turned by the build type's AdditionalModelRotation), made on a worker and kept in a small cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ set(DUGC_SOURCES
|
||||
"UgcBricks.cpp"
|
||||
"UgcFormats.cpp"
|
||||
"UgcIconParams.cpp"
|
||||
"UgcIconPose.cpp"
|
||||
"UgcJobs.cpp"
|
||||
"UgcModel.cpp"
|
||||
"UgcModular.cpp"
|
||||
|
||||
@@ -21,6 +21,9 @@ namespace UgcIconParams {
|
||||
{ "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; } },
|
||||
|
||||
99
dUgcServer/UgcIconPose.cpp
Normal file
99
dUgcServer/UgcIconPose.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
#include "UgcIconPose.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
namespace UgcIconPose {
|
||||
glm::vec3 CameraDirection(float yawDegrees, float pitchDegrees) {
|
||||
const float yaw = glm::radians(yawDegrees), pitch = glm::radians(pitchDegrees);
|
||||
return { std::sin(yaw) * std::cos(pitch), std::sin(pitch), std::cos(yaw) * std::cos(pitch) };
|
||||
}
|
||||
|
||||
glm::vec2 DirectionAngles(const glm::vec3& direction) {
|
||||
const float horizontal = std::sqrt(direction.x * direction.x + direction.z * direction.z);
|
||||
return { glm::degrees(std::atan2(direction.x, direction.z)), glm::degrees(std::atan2(direction.y, horizontal)) };
|
||||
}
|
||||
|
||||
glm::mat4 ModelRotation(float yawDegrees, float pitchDegrees, float rollDegrees) {
|
||||
auto rotation = glm::rotate(glm::mat4(1.0f), glm::radians(yawDegrees), glm::vec3(0.0f, 1.0f, 0.0f));
|
||||
rotation = glm::rotate(rotation, glm::radians(pitchDegrees), glm::vec3(1.0f, 0.0f, 0.0f));
|
||||
return glm::rotate(rotation, glm::radians(rollDegrees), glm::vec3(0.0f, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
glm::vec3 RotationAngles(const glm::mat4& m) {
|
||||
// Ry*Rx*Rz: m[2][1] (column 2, row 1) is -sin(pitch); as three.js's Euler.setFromRotationMatrix for 'YXZ'
|
||||
const float m13 = m[2][0], m23 = m[2][1], m33 = m[2][2];
|
||||
const float m21 = m[0][1], m22 = m[1][1], m11 = m[0][0], m31 = m[0][2];
|
||||
const float pitch = std::asin(std::clamp(-m23, -1.0f, 1.0f));
|
||||
float yaw, roll;
|
||||
if (std::abs(m23) < 0.9999999f) {
|
||||
yaw = std::atan2(m13, m33);
|
||||
roll = std::atan2(m21, m22);
|
||||
} else {
|
||||
yaw = std::atan2(-m31, m11);
|
||||
roll = 0.0f;
|
||||
}
|
||||
return { glm::degrees(yaw), glm::degrees(pitch), glm::degrees(roll) };
|
||||
}
|
||||
|
||||
glm::vec3 Frame::IconPoint(const glm::vec3& position) const {
|
||||
const auto clip = viewProjection * glm::vec4(position, 1.0f);
|
||||
const float w = clip.w > 1e-6f ? clip.w : 1e-6f;
|
||||
return { 0.5f + offset.x + (clip.x / w - centerX) * scale * 0.5f, 0.5f - offset.y - (clip.y / w - centerY) * scale * 0.5f, clip.z / w };
|
||||
}
|
||||
|
||||
glm::vec4 Frame::IconRect() const {
|
||||
// Inverse of IconPoint at the icon's edges (0 and 1)
|
||||
const float half = 2.0f / scale;
|
||||
return { centerX + (0.0f - 0.5f - offset.x) * half, centerY + (0.5f - offset.y - 1.0f) * half,
|
||||
centerX + (1.0f - 0.5f - offset.x) * half, centerY + (0.5f - offset.y) * half };
|
||||
}
|
||||
|
||||
Frame Compute(const std::vector<const std::vector<glm::vec3>*>& positions, const Camera& camera) {
|
||||
constexpr float INF = std::numeric_limits<float>::infinity();
|
||||
Frame frame;
|
||||
glm::vec3 min(INF), max(-INF);
|
||||
for (const auto* list : positions) {
|
||||
for (const auto& p : *list) {
|
||||
min = glm::min(min, p);
|
||||
max = glm::max(max, p);
|
||||
}
|
||||
}
|
||||
if (min.x > max.x) {
|
||||
frame.center = glm::vec3(0.0f);
|
||||
frame.radius = 1.0f;
|
||||
} else {
|
||||
frame.center = (min + max) * 0.5f;
|
||||
frame.radius = std::max(glm::length(max - min) * 0.5f, 0.01f);
|
||||
}
|
||||
const auto direction = CameraDirection(camera.yawDegrees, camera.pitchDegrees);
|
||||
frame.fov = glm::radians(std::clamp(camera.fovDegrees, 1.0f, 120.0f));
|
||||
frame.distance = frame.radius / std::sin(frame.fov * 0.5f);
|
||||
frame.eye = frame.center + direction * frame.distance;
|
||||
const float nearPlane = std::max(frame.distance - frame.radius * 1.5f, frame.distance * 0.01f);
|
||||
frame.viewProjection = glm::perspective(frame.fov, 1.0f, nearPlane, frame.distance + frame.radius * 1.5f) *
|
||||
glm::lookAt(frame.eye, frame.center, glm::vec3(0.0f, 1.0f, 0.0f));
|
||||
|
||||
float minX = INF, minY = INF, maxX = -INF, maxY = -INF;
|
||||
for (const auto* list : positions) {
|
||||
for (const auto& p : *list) {
|
||||
const auto clip = frame.viewProjection * glm::vec4(p, 1.0f);
|
||||
if (clip.w <= 0.0f) continue;
|
||||
minX = std::min(minX, clip.x / clip.w);
|
||||
maxX = std::max(maxX, clip.x / clip.w);
|
||||
minY = std::min(minY, clip.y / clip.w);
|
||||
maxY = std::max(maxY, clip.y / clip.w);
|
||||
}
|
||||
}
|
||||
if (minX > maxX) return frame;
|
||||
frame.centerX = (minX + maxX) * 0.5f;
|
||||
frame.centerY = (minY + maxY) * 0.5f;
|
||||
frame.scale = 2.0f / (std::max({ maxX - minX, maxY - minY, 1e-6f }) * std::max(camera.margin, 0.1f));
|
||||
frame.offset = { camera.offsetX, camera.offsetY };
|
||||
frame.ok = true;
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
56
dUgcServer/UgcIconPose.h
Normal file
56
dUgcServer/UgcIconPose.h
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
/**
|
||||
* Where the icon's camera is and how the model is turned for it, worked out the same way in the icon renderer and in
|
||||
* the dashboard's pose editor (static/js/ugc-pose.js mirrors these functions, so its 3D view shows what the icon
|
||||
* will). Angles are degrees. Pure.
|
||||
*
|
||||
* 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 (Frame): scaled so the larger side fills the icon less the margin, then shifted.
|
||||
*/
|
||||
namespace UgcIconPose {
|
||||
// From the model towards the camera: yaw around +Y from +Z towards +X, pitch up from the ground
|
||||
glm::vec3 CameraDirection(float yawDegrees, float pitchDegrees);
|
||||
// The inverse: {yaw, pitch} of a direction (need not be unit length)
|
||||
glm::vec2 DirectionAngles(const glm::vec3& direction);
|
||||
|
||||
// The model's turn: yaw around +Y, then pitch around +X, then roll around +Z (R = Ry * Rx * Rz, three.js's 'YXZ' Euler order)
|
||||
glm::mat4 ModelRotation(float yawDegrees, float pitchDegrees, float rollDegrees);
|
||||
// The inverse: {yaw, pitch, roll} of a rotation (pitch in -90..90; at +-90 the roll is folded into the yaw)
|
||||
glm::vec3 RotationAngles(const glm::mat4& rotation);
|
||||
|
||||
struct Camera {
|
||||
float yawDegrees{};
|
||||
float pitchDegrees{};
|
||||
float fovDegrees{ 40.0f };
|
||||
float margin{ 1.0f }; // 1: the model's larger projected side fills the icon
|
||||
float offsetX{}; // share of the icon's width the model is moved right
|
||||
float offsetY{}; // and up
|
||||
};
|
||||
|
||||
struct Frame {
|
||||
bool ok{};
|
||||
glm::vec3 center{}; // of the model's bounds
|
||||
float radius{}; // half their diagonal
|
||||
glm::vec3 eye{};
|
||||
float fov{}; // radians
|
||||
float distance{};
|
||||
glm::mat4 viewProjection{ 1.0f };
|
||||
float centerX{}, centerY{}; // centre of the projected bounds (NDC)
|
||||
float scale{ 1.0f }; // NDC -> icon: 2 / (larger projected side * margin)
|
||||
glm::vec2 offset{};
|
||||
|
||||
// A point's place in the icon: x right and y down, 0..1 across it, and its depth (NDC z)
|
||||
glm::vec3 IconPoint(const glm::vec3& position) const;
|
||||
// The icon's square in the camera's NDC: {minX, minY, maxX, maxY}
|
||||
glm::vec4 IconRect() const;
|
||||
};
|
||||
|
||||
// The frame of an already turned model's vertices (any number of lists)
|
||||
Frame Compute(const std::vector<const std::vector<glm::vec3>*>& positions, const Camera& camera);
|
||||
}
|
||||
@@ -202,47 +202,70 @@ namespace UgcJobs {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
Outcome ProcessModular(const ModularInput& input, const std::filesystem::path& res, const Settings& settings) {
|
||||
Outcome outcome;
|
||||
std::optional<UgcModel::Model> AssembleModular(const ModularInput& input, const std::filesystem::path& res, glm::mat4& additionalRotation, std::string& error, std::string& note) {
|
||||
const auto build = UgcModular::ParseBuild(input.buildXml);
|
||||
if (!build) {
|
||||
outcome.error = "the build type has no topology in ModularBuildComponent";
|
||||
return outcome;
|
||||
error = "the build type has no topology in ModularBuildComponent";
|
||||
return std::nullopt;
|
||||
}
|
||||
additionalRotation = build->additionalRotation;
|
||||
std::vector<UgcModular::Module> modules;
|
||||
for (const auto& moduleInput : input.modules) {
|
||||
const auto path = UgcBricks::ResolvePath(res, moduleInput.renderAsset);
|
||||
const auto data = path ? UgcBricks::ReadFile(*path) : std::nullopt;
|
||||
if (!data) {
|
||||
outcome.note += "module " + std::to_string(moduleInput.lot) + " has no mesh (" + moduleInput.renderAsset + "); ";
|
||||
note += "module " + std::to_string(moduleInput.lot) + " has no mesh (" + moduleInput.renderAsset + "); ";
|
||||
continue;
|
||||
}
|
||||
std::string error;
|
||||
auto nif = NifFile::Parse(*data, 0, error);
|
||||
std::string nifError;
|
||||
auto nif = NifFile::Parse(*data, 0, nifError);
|
||||
if (!nif) {
|
||||
outcome.note += "module " + std::to_string(moduleInput.lot) + ": " + error + "; ";
|
||||
note += "module " + std::to_string(moduleInput.lot) + ": " + nifError + "; ";
|
||||
continue;
|
||||
}
|
||||
modules.push_back({ moduleInput.partCode, std::move(*nif), UgcModular::ParseModuleConnections(moduleInput.moduleXml) });
|
||||
}
|
||||
if (modules.empty()) {
|
||||
outcome.error = "none of the modules have a mesh";
|
||||
if (!outcome.note.empty()) outcome.error += " (" + outcome.note + ")";
|
||||
return outcome;
|
||||
error = "none of the modules have a mesh";
|
||||
if (!note.empty()) error += " (" + note + ")";
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto model = UgcModular::Assemble(*build, modules, outcome.note);
|
||||
auto model = UgcModular::Assemble(*build, modules, note);
|
||||
if (model.Empty()) {
|
||||
outcome.error = "the modules have no triangles";
|
||||
return outcome;
|
||||
error = "the modules have no triangles";
|
||||
return std::nullopt;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
Outcome ProcessModular(const ModularInput& input, const std::filesystem::path& res, const Settings& settings) {
|
||||
Outcome outcome;
|
||||
glm::mat4 additionalRotation{ 1.0f };
|
||||
const auto model = AssembleModular(input, res, additionalRotation, outcome.error, outcome.note);
|
||||
if (!model) return outcome;
|
||||
auto options = ModularIconOptions(input, settings);
|
||||
options.modelRotation = build->additionalRotation * options.modelRotation;
|
||||
AddIcon(outcome.files, model, options);
|
||||
options.modelRotation = additionalRotation;
|
||||
AddIcon(outcome.files, *model, options);
|
||||
outcome.files["combo.json"] = nlohmann::json{ { "key", input.key }, { "buildType", input.buildType } }.dump();
|
||||
outcome.ok = true;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
std::optional<std::string> AssemblyNif(const ModularInput& input, const std::filesystem::path& res, std::string& error) {
|
||||
glm::mat4 additionalRotation{ 1.0f };
|
||||
std::string note;
|
||||
auto model = AssembleModular(input, res, additionalRotation, error, note);
|
||||
if (!model) return std::nullopt;
|
||||
// Turned as the icon renderer turns it before the pose's own rotation, so the editor's model rotation starts from here
|
||||
model->opaque.Transform(additionalRotation);
|
||||
model->transparent.Transform(additionalRotation);
|
||||
const auto opaque = UgcModel::Split(model->opaque), transparent = UgcModel::Split(model->transparent);
|
||||
std::vector<UgcFormats::NifShape> shapes;
|
||||
for (const auto& piece : opaque) shapes.push_back({ "S01_Opaque_Model", &piece, false });
|
||||
for (const auto& piece : transparent) shapes.push_back({ "S01_Alpha_Model", &piece, true });
|
||||
return UgcFormats::WriteNif("SceneNode_Assembly", shapes);
|
||||
}
|
||||
|
||||
UgcRender::IconOptions ModularIconOptions(const ModularInput& input, const Settings& settings) {
|
||||
auto options = settings.icon;
|
||||
UgcIconParams::Apply(options, input.iconValues);
|
||||
|
||||
@@ -84,6 +84,14 @@ namespace UgcJobs {
|
||||
// The icon options for a car or rocket: the settings, then its build type's preset and the combination's override
|
||||
UgcRender::IconOptions ModularIconOptions(const ModularInput& input, const Settings& settings);
|
||||
|
||||
// A modular build's modules' meshes put together (UgcModular::Assemble), not yet turned by the build type's
|
||||
// AdditionalModelRotation (given back in `additionalRotation`); nullopt with `error` when there's nothing to draw
|
||||
std::optional<UgcModel::Model> AssembleModular(const ModularInput& input, const std::filesystem::path& res, glm::mat4& additionalRotation, std::string& error, std::string& note);
|
||||
|
||||
// A modular build's icon (icon.png, icon.dds download, combo.json), from its modules' meshes put together
|
||||
Outcome ProcessModular(const ModularInput& input, const std::filesystem::path& res, const Settings& settings);
|
||||
|
||||
// The assembled mesh as the icon renderer turns it (AdditionalModelRotation applied), as a .nif for the dashboard's
|
||||
// pose editor; nullopt with `error` when there's nothing to draw
|
||||
std::optional<std::string> AssemblyNif(const ModularInput& input, const std::filesystem::path& res, std::string& error);
|
||||
}
|
||||
|
||||
@@ -157,7 +157,17 @@ void UgcProcessor::Worker() {
|
||||
HTTPReply reply;
|
||||
try {
|
||||
UgcJobs::Outcome outcome{ false, "cancelled" };
|
||||
if (!job.preview.Cancelled() && job.kind == Kind::MODULAR) {
|
||||
if (job.assembly) {
|
||||
if (!job.preview.Cancelled()) {
|
||||
auto nif = UgcJobs::AssemblyNif(job.modular, m_Library.GetResPath(), outcome.error);
|
||||
if (nif) {
|
||||
auto shared = std::make_shared<const std::string>(std::move(*nif));
|
||||
CacheAssembly(job.modular.key, shared);
|
||||
outcome.ok = true;
|
||||
outcome.files["assembly.nif"] = *shared;
|
||||
}
|
||||
}
|
||||
} else if (!job.preview.Cancelled() && job.kind == Kind::MODULAR) {
|
||||
outcome = UgcJobs::ProcessModular(job.modular, m_Library.GetResPath(), settings);
|
||||
} else if (!job.preview.Cancelled()) {
|
||||
// A player model's icon from its stored .nif
|
||||
@@ -167,7 +177,12 @@ void UgcProcessor::Worker() {
|
||||
outcome.ok = nif && UgcJobs::IconFromNif(*nif, options, outcome.files, outcome.error);
|
||||
if (!nif) outcome.error = "the model has no stored .nif yet";
|
||||
}
|
||||
if (outcome.ok && outcome.files.contains("icon.png")) {
|
||||
if (outcome.ok && outcome.files.contains("assembly.nif")) {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::APPLICATION_OCTET_STREAM;
|
||||
reply.message = std::move(outcome.files["assembly.nif"]);
|
||||
reply.headers.push_back("Cache-Control: no-store");
|
||||
} else if (outcome.ok && outcome.files.contains("icon.png")) {
|
||||
reply.status = eHTTPStatusCode::OK;
|
||||
reply.contentType = eContentType::IMAGE_PNG;
|
||||
reply.message = std::move(outcome.files["icon.png"]);
|
||||
@@ -529,6 +544,59 @@ bool UgcProcessor::QueuePreview(Kind kind, LWOOBJID id, const std::string& modul
|
||||
return true;
|
||||
}
|
||||
|
||||
std::shared_ptr<const std::string> UgcProcessor::CachedAssembly(const std::string& key) {
|
||||
std::lock_guard lock(m_AssemblyMutex);
|
||||
const auto it = std::find_if(m_Assemblies.begin(), m_Assemblies.end(), [&key](const auto& entry) { return entry.first == key; });
|
||||
if (it == m_Assemblies.end()) return nullptr;
|
||||
m_Assemblies.splice(m_Assemblies.begin(), m_Assemblies, it);
|
||||
return m_Assemblies.front().second;
|
||||
}
|
||||
|
||||
void UgcProcessor::CacheAssembly(const std::string& key, std::shared_ptr<const std::string> nif) {
|
||||
constexpr size_t MAX_ENTRIES = 32;
|
||||
constexpr size_t MAX_BYTES = 64ull * 1024 * 1024;
|
||||
std::lock_guard lock(m_AssemblyMutex);
|
||||
std::erase_if(m_Assemblies, [&key](const auto& entry) { return entry.first == key; });
|
||||
m_Assemblies.emplace_front(key, std::move(nif));
|
||||
size_t bytes = 0, kept = 0;
|
||||
for (auto it = m_Assemblies.begin(); it != m_Assemblies.end(); ++it, kept++) {
|
||||
bytes += it->second->size();
|
||||
if (kept >= MAX_ENTRIES || (kept > 0 && bytes > MAX_BYTES)) {
|
||||
m_Assemblies.erase(it, m_Assemblies.end());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool UgcProcessor::QueueAssembly(const std::string& modules, DeferredReply reply, std::string& error) {
|
||||
const auto key = UgcModularKey::Normalize(modules);
|
||||
if (key.empty()) {
|
||||
error = "no modules";
|
||||
return false;
|
||||
}
|
||||
if (const auto cached = CachedAssembly(key)) {
|
||||
HTTPReply out;
|
||||
out.status = eHTTPStatusCode::OK;
|
||||
out.contentType = eContentType::APPLICATION_OCTET_STREAM;
|
||||
out.message = *cached;
|
||||
out.headers.push_back("Cache-Control: no-store");
|
||||
reply.Send(std::move(out));
|
||||
return true;
|
||||
}
|
||||
Job job{ Kind::MODULAR, 0, 0 };
|
||||
if (!UgcCdClient::GatherModular(modules, job.modular, error)) return false;
|
||||
job.modular.key = key;
|
||||
job.assembly = true;
|
||||
job.preview = std::move(reply);
|
||||
{
|
||||
std::lock_guard lock(m_Mutex);
|
||||
job.memory = UgcJobs::EstimateMemory(64, m_Settings);
|
||||
m_Jobs.push_front(std::move(job));
|
||||
}
|
||||
m_Wake.notify_all();
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t UgcProcessor::RegenerateIcons(const std::string& kind) {
|
||||
std::vector<Job> jobs;
|
||||
UgcJobs::Settings settings;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <list>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
@@ -82,6 +84,13 @@ public:
|
||||
*/
|
||||
bool QueuePreview(Kind kind, LWOOBJID id, const std::string& modules, const UgcIconParams::Values& values, DeferredReply reply, std::string& error);
|
||||
|
||||
/**
|
||||
* Main thread: the assembled mesh of a module combination as a .nif (UgcJobs::AssemblyNif), for the dashboard's
|
||||
* pose editor: from a small cache of the last ones asked for, else made on a worker (within the budgets, ahead of
|
||||
* the queue) and cached. `reply` gets the .nif. False (and `error`) when the modules can't be told.
|
||||
*/
|
||||
bool QueueAssembly(const std::string& modules, DeferredReply reply, std::string& error);
|
||||
|
||||
// Main thread: queues every stored icon of a kind ("model", or "build<type>" for cars and rockets) to be drawn again
|
||||
// with the current settings, presets and overrides; only icons (models' from their stored .nif). How many.
|
||||
size_t RegenerateIcons(const std::string& kind);
|
||||
@@ -142,6 +151,7 @@ private:
|
||||
size_t parts{};
|
||||
DeferredReply preview; // an icon preview: answered with the PNG, nothing stored
|
||||
bool iconOnly{}; // a model's icon drawn again from its stored .nif
|
||||
bool assembly{}; // with `preview`: answered with the assembled .nif instead of an icon
|
||||
UgcIconParams::Values iconValues; // models: the preset and override (UgcIconParams)
|
||||
};
|
||||
|
||||
@@ -207,4 +217,10 @@ private:
|
||||
int64_t time{};
|
||||
};
|
||||
std::deque<LogEntry> m_Log; // the last results
|
||||
|
||||
// Assembled meshes (QueueAssembly) by combination, newest first; workers add to it
|
||||
std::mutex m_AssemblyMutex;
|
||||
std::list<std::pair<std::string, std::shared_ptr<const std::string>>> m_Assemblies;
|
||||
std::shared_ptr<const std::string> CachedAssembly(const std::string& key);
|
||||
void CacheAssembly(const std::string& key, std::shared_ptr<const std::string> nif);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
#include "UgcIconPose.h"
|
||||
#include "UgcPalette.h"
|
||||
#include "UgcThrottle.h"
|
||||
|
||||
@@ -398,39 +399,21 @@ namespace UgcRender {
|
||||
if (source.Empty()) return image;
|
||||
|
||||
UgcModel::Model model = source;
|
||||
model.opaque.Transform(options.modelRotation);
|
||||
model.transparent.Transform(options.modelRotation);
|
||||
glm::vec3 center{};
|
||||
float radius{};
|
||||
Bounds(model, center, radius);
|
||||
const auto rotation = UgcIconPose::ModelRotation(options.modelYawDegrees, options.modelPitchDegrees, options.modelRollDegrees) * options.modelRotation;
|
||||
model.opaque.Transform(rotation);
|
||||
model.transparent.Transform(rotation);
|
||||
|
||||
const float yaw = glm::radians(options.yawDegrees), pitch = glm::radians(options.pitchDegrees);
|
||||
const glm::vec3 dir(std::sin(yaw) * std::cos(pitch), std::sin(pitch), std::cos(yaw) * std::cos(pitch));
|
||||
const float fov = glm::radians(std::clamp(options.fovDegrees, 1.0f, 120.0f));
|
||||
const float distance = radius / std::sin(fov * 0.5f);
|
||||
const glm::vec3 eye = center + dir * distance;
|
||||
const glm::mat4 viewProjection = glm::perspective(fov, 1.0f, std::max(distance - radius * 1.5f, distance * 0.01f), distance + radius * 1.5f) *
|
||||
glm::lookAt(eye, center, glm::vec3(0.0f, 1.0f, 0.0f));
|
||||
|
||||
// Frame the model: its projected bounds, scaled to fill the icon less the margin
|
||||
float minX = INF, minY = INF, maxX = -INF, maxY = -INF;
|
||||
for (const auto* mesh : { &model.opaque, &model.transparent }) {
|
||||
for (const auto& position : mesh->positions) {
|
||||
const auto clip = viewProjection * glm::vec4(position, 1.0f);
|
||||
if (clip.w <= 0.0f) continue;
|
||||
minX = std::min(minX, clip.x / clip.w);
|
||||
maxX = std::max(maxX, clip.x / clip.w);
|
||||
minY = std::min(minY, clip.y / clip.w);
|
||||
maxY = std::max(maxY, clip.y / clip.w);
|
||||
}
|
||||
}
|
||||
if (minX > maxX) return image;
|
||||
const float centerX = (minX + maxX) * 0.5f, centerY = (minY + maxY) * 0.5f;
|
||||
const float scale = 2.0f / (std::max({ maxX - minX, maxY - minY, 1e-6f }) * std::max(options.margin, 0.1f));
|
||||
// The camera and the crop to the model's projected bounds (shared with the dashboard's pose editor)
|
||||
const auto frame = UgcIconPose::Compute({ &model.opaque.positions, &model.transparent.positions },
|
||||
{ options.yawDegrees, options.pitchDegrees, options.fovDegrees, options.margin, options.offsetX, options.offsetY });
|
||||
if (!frame.ok) return image;
|
||||
const glm::vec3 center = frame.center;
|
||||
const float radius = frame.radius;
|
||||
const glm::vec3 eye = frame.eye;
|
||||
const glm::vec3 dir = glm::normalize(eye - center);
|
||||
const auto project = [&](const glm::vec3& position) {
|
||||
const auto clip = viewProjection * glm::vec4(position, 1.0f);
|
||||
const float w = clip.w > 1e-6f ? clip.w : 1e-6f;
|
||||
return glm::vec3((0.5f + options.offsetX + (clip.x / w - centerX) * scale * 0.5f) * n, (0.5f - options.offsetY - (clip.y / w - centerY) * scale * 0.5f) * n, clip.z / w);
|
||||
const auto point = frame.IconPoint(position);
|
||||
return glm::vec3(point.x * n, point.y * n, point.z);
|
||||
};
|
||||
|
||||
// Linear, premultiplied
|
||||
|
||||
@@ -40,7 +40,10 @@ namespace UgcRender {
|
||||
float margin{ 1.03f }; // 1 fills the icon, more leaves a border
|
||||
float offsetX{}; // the model moved right by this share of the icon's width (after framing)
|
||||
float offsetY{}; // and up by this share of its height
|
||||
glm::mat4 modelRotation{ 1.0f }; // applied to the model before the camera looks at it
|
||||
float modelYawDegrees{}; // the model turned (UgcIconPose::ModelRotation), after modelRotation
|
||||
float modelPitchDegrees{};
|
||||
float modelRollDegrees{};
|
||||
glm::mat4 modelRotation{ 1.0f }; // the model's own turn before that (a build type's AdditionalModelRotation)
|
||||
float sunYawDegrees{ 21.0f };
|
||||
float sunPitchDegrees{ 50.3f };
|
||||
float sunStrength{ 2.5f };
|
||||
|
||||
@@ -327,6 +327,21 @@ namespace {
|
||||
}
|
||||
} });
|
||||
|
||||
Game::web.RegisterHTTPRoute({ .path = "/admin/assembly", .method = eHTTPMethod::POST, .middleware = {},
|
||||
.handle = [admin](HTTPReply& reply, const HTTPContext& context) {
|
||||
std::optional<nlohmann::json> body;
|
||||
if (!admin(reply, context, body)) return;
|
||||
auto deferred = Web::Defer(reply, context);
|
||||
std::string error;
|
||||
if (!g_Processor->QueueAssembly(body->value("modules", std::string()), deferred, error)) {
|
||||
HTTPReply out;
|
||||
out.status = eHTTPStatusCode::BAD_REQUEST;
|
||||
out.contentType = eContentType::APPLICATION_JSON;
|
||||
out.message = nlohmann::json{ { "success", false }, { "error", error } }.dump();
|
||||
deferred.Send(std::move(out));
|
||||
}
|
||||
} });
|
||||
|
||||
Game::web.RegisterHTTPRoute({ .path = "/admin/regenerate-icons", .method = eHTTPMethod::POST, .middleware = {},
|
||||
.handle = [admin](HTTPReply& reply, const HTTPContext& context) {
|
||||
std::optional<nlohmann::json> body;
|
||||
|
||||
@@ -83,6 +83,7 @@ glow_strength=6
|
||||
icon_size=128
|
||||
|
||||
# How icons are framed and lit: icon_yaw, icon_pitch, icon_fov, icon_margin, icon_offset_x, icon_offset_y,
|
||||
# icon_model_yaw, icon_model_pitch, icon_model_roll,
|
||||
# icon_sun_yaw, icon_sun_pitch, icon_sun_light, icon_world_light, icon_fill, icon_specular, icon_shininess,
|
||||
# icon_exposure, icon_contrast, icon_shadow_strength, icon_ao_strength. Their defaults (the framing from LU Toolbox's icon
|
||||
# renderer, the light matched to the game's own model icons) and ranges are listed once in the server
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "UgcJobs.h"
|
||||
#include "IUgc.h"
|
||||
#include "UgcIconParams.h"
|
||||
#include "UgcIconPose.h"
|
||||
#include "UgcKeys.h"
|
||||
#include "UgcModular.h"
|
||||
#include "UgcPalette.h"
|
||||
@@ -735,3 +736,163 @@ TEST(UgcStates, NamesComeFromTheEnum) {
|
||||
EXPECT_FALSE(IUgc::ParseProcessState("nonsense").has_value());
|
||||
EXPECT_EQ(magic_enum::enum_count<IUgc::eProcessState>(), 4u);
|
||||
}
|
||||
|
||||
TEST(UgcIconPose, AnglesRoundTrip) {
|
||||
// The camera's direction and back
|
||||
for (const float yaw : { -170.0f, -53.0f, 0.0f, 21.0f, 90.0f, 179.0f }) {
|
||||
for (const float pitch : { -80.0f, -10.0f, 0.0f, 19.54f, 60.0f }) {
|
||||
const auto direction = UgcIconPose::CameraDirection(yaw, pitch);
|
||||
EXPECT_NEAR(glm::length(direction), 1.0f, 1e-5f);
|
||||
const auto angles = UgcIconPose::DirectionAngles(direction * 3.0f);
|
||||
EXPECT_NEAR(angles.x, yaw, 1e-3f);
|
||||
EXPECT_NEAR(angles.y, pitch, 1e-3f);
|
||||
}
|
||||
}
|
||||
// Yaw 0 looks from +Z, yaw 90 from +X, pitch 90 from above
|
||||
EXPECT_NEAR(UgcIconPose::CameraDirection(0, 0).z, 1.0f, 1e-6f);
|
||||
EXPECT_NEAR(UgcIconPose::CameraDirection(90, 0).x, 1.0f, 1e-6f);
|
||||
EXPECT_NEAR(UgcIconPose::CameraDirection(0, 90).y, 1.0f, 1e-6f);
|
||||
|
||||
// The model's rotation and back (Ry * Rx * Rz)
|
||||
for (const auto& angles : { glm::vec3(0), glm::vec3(30, 20, 10), glm::vec3(-120, -45, 170), glm::vec3(90, 89, -90), glm::vec3(179, 0, -179) }) {
|
||||
const auto rotation = UgcIconPose::ModelRotation(angles.x, angles.y, angles.z);
|
||||
const auto back = UgcIconPose::RotationAngles(rotation);
|
||||
EXPECT_NEAR(back.x, angles.x, 1e-2f);
|
||||
EXPECT_NEAR(back.y, angles.y, 1e-2f);
|
||||
EXPECT_NEAR(back.z, angles.z, 1e-2f);
|
||||
// Same matrix from the angles found
|
||||
const auto again = UgcIconPose::ModelRotation(back.x, back.y, back.z);
|
||||
for (int c = 0; c < 4; c++) for (int r = 0; r < 4; r++) EXPECT_NEAR(again[c][r], rotation[c][r], 1e-4f);
|
||||
}
|
||||
// The order: yaw turns +X towards -Z, pitch turns +Y towards +Z, roll turns +X towards +Y, applied roll first
|
||||
const auto yawed = UgcIconPose::ModelRotation(90, 0, 0) * glm::vec4(1, 0, 0, 0);
|
||||
EXPECT_NEAR(yawed.z, -1.0f, 1e-5f);
|
||||
const auto pitched = UgcIconPose::ModelRotation(0, 90, 0) * glm::vec4(0, 1, 0, 0);
|
||||
EXPECT_NEAR(pitched.z, 1.0f, 1e-5f);
|
||||
const auto rolled = UgcIconPose::ModelRotation(0, 0, 90) * glm::vec4(1, 0, 0, 0);
|
||||
EXPECT_NEAR(rolled.y, 1.0f, 1e-5f);
|
||||
const auto both = UgcIconPose::ModelRotation(90, 0, 90) * glm::vec4(1, 0, 0, 0); // rolled to +Y, which the yaw leaves
|
||||
EXPECT_NEAR(both.y, 1.0f, 1e-5f);
|
||||
// Glm's own YXZ Euler matrix agrees
|
||||
const auto glmYxz = glm::rotate(glm::rotate(glm::rotate(glm::mat4(1.0f), glm::radians(30.0f), glm::vec3(0, 1, 0)), glm::radians(20.0f), glm::vec3(1, 0, 0)), glm::radians(10.0f), glm::vec3(0, 0, 1));
|
||||
const auto ours = UgcIconPose::ModelRotation(30, 20, 10);
|
||||
for (int c = 0; c < 4; c++) for (int r = 0; r < 4; r++) EXPECT_NEAR(ours[c][r], glmYxz[c][r], 1e-6f);
|
||||
}
|
||||
|
||||
TEST(UgcIconPose, FramingFillsTheIcon) {
|
||||
const std::vector<glm::vec3> box = { { -1, 0, -2 }, { 3, 0, -2 }, { -1, 2, -2 }, { 3, 2, -2 }, { -1, 0, 1 }, { 3, 0, 1 }, { -1, 2, 1 }, { 3, 2, 1 } };
|
||||
UgcIconPose::Camera camera{ 53.36f, 19.54f, 39.6f, 1.0f, 0.0f, 0.0f };
|
||||
auto frame = UgcIconPose::Compute({ &box }, camera);
|
||||
ASSERT_TRUE(frame.ok);
|
||||
EXPECT_NEAR(frame.distance, frame.radius / std::sin(glm::radians(39.6f) * 0.5f), 1e-4f);
|
||||
float minX = 2, maxX = -2, minY = 2, maxY = -2;
|
||||
for (const auto& p : box) {
|
||||
const auto point = frame.IconPoint(p);
|
||||
minX = std::min(minX, point.x), maxX = std::max(maxX, point.x), minY = std::min(minY, point.y), maxY = std::max(maxY, point.y);
|
||||
}
|
||||
// Margin 1: the larger side spans the icon exactly, both centred
|
||||
EXPECT_NEAR(std::max(maxX - minX, maxY - minY), 1.0f, 1e-4f);
|
||||
EXPECT_NEAR((minX + maxX) * 0.5f, 0.5f, 1e-4f);
|
||||
EXPECT_NEAR((minY + maxY) * 0.5f, 0.5f, 1e-4f);
|
||||
|
||||
// The icon's rectangle in NDC maps back onto the icon's corners, also shifted and with a border
|
||||
camera.margin = 1.5f;
|
||||
camera.offsetX = 0.2f;
|
||||
camera.offsetY = -0.1f;
|
||||
frame = UgcIconPose::Compute({ &box }, camera);
|
||||
const auto rect = frame.IconRect();
|
||||
const auto corner = [&](float ndcX, float ndcY) {
|
||||
// A point at that NDC place: through the inverse view-projection
|
||||
const auto world = glm::inverse(frame.viewProjection) * glm::vec4(ndcX, ndcY, 0.5f, 1.0f);
|
||||
return frame.IconPoint(glm::vec3(world) / world.w);
|
||||
};
|
||||
const auto topLeft = corner(rect.x, rect.w), bottomRight = corner(rect.z, rect.y);
|
||||
EXPECT_NEAR(topLeft.x, 0.0f, 1e-3f);
|
||||
EXPECT_NEAR(topLeft.y, 0.0f, 1e-3f);
|
||||
EXPECT_NEAR(bottomRight.x, 1.0f, 1e-3f);
|
||||
EXPECT_NEAR(bottomRight.y, 1.0f, 1e-3f);
|
||||
// The model's projected size is the icon's over the margin
|
||||
minX = 2, maxX = -2;
|
||||
for (const auto& p : box) minX = std::min(minX, frame.IconPoint(p).x), maxX = std::max(maxX, frame.IconPoint(p).x);
|
||||
float minY2 = 2, maxY2 = -2;
|
||||
for (const auto& p : box) minY2 = std::min(minY2, frame.IconPoint(p).y), maxY2 = std::max(maxY2, frame.IconPoint(p).y);
|
||||
EXPECT_NEAR(std::max(maxX - minX, maxY2 - minY2), 1.0f / 1.5f, 1e-4f);
|
||||
EXPECT_NEAR((minX + maxX) * 0.5f, 0.7f, 1e-4f);
|
||||
EXPECT_NEAR((minY2 + maxY2) * 0.5f, 0.6f, 1e-4f);
|
||||
}
|
||||
|
||||
TEST(UgcIconPose, RendererHonoursTheModelRotation) {
|
||||
UgcBricks::BrickLibrary library(MakeRes(), 0);
|
||||
library.SetMaterials({ { 21, { 222, 0, 13, 255 } } });
|
||||
std::string error;
|
||||
// A long bar along X: seen from the front (yaw 0) it is wide; turned 90 degrees it is narrow
|
||||
auto model = UgcModel::Build(UgcModel::ParseLxfml(R"(<LXFML versionMajor="5"><Bricks>
|
||||
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,0,0,0"/></Part></Brick>
|
||||
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,1,0,0"/></Part></Brick>
|
||||
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,2,0,0"/></Part></Brick>
|
||||
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,3,0,0"/></Part></Brick>
|
||||
</Bricks></LXFML>)", error), library);
|
||||
const auto coverage = [](const UgcRender::Image& image, bool columns) {
|
||||
int count = 0;
|
||||
for (int i = 0; i < image.width; i++) {
|
||||
bool any = false;
|
||||
for (int j = 0; j < image.height && !any; j++) any = image.rgba[((columns ? j : i) * image.width + (columns ? i : j)) * 4 + 3] > 0;
|
||||
count += any;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
UgcRender::IconOptions options{ 64, 1 };
|
||||
options.yawDegrees = 0.0f;
|
||||
options.pitchDegrees = 0.0f;
|
||||
options.margin = 1.0f;
|
||||
const auto front = UgcRender::RenderIcon(model, options);
|
||||
EXPECT_GT(coverage(front, true), coverage(front, false) * 2); // wider than tall
|
||||
options.modelYawDegrees = 90.0f;
|
||||
const auto turned = UgcRender::RenderIcon(model, options);
|
||||
EXPECT_NEAR(coverage(turned, true), coverage(turned, false), 12); // end on: its square end, the rest behind it
|
||||
// Turning the model is the same as turning the camera the other way (the light turns with the camera here: none)
|
||||
UgcIconParams::Apply(options, { { "modelYaw", 0.0f }, { "modelRoll", 90.0f } });
|
||||
EXPECT_FLOAT_EQ(options.modelRollDegrees, 90.0f);
|
||||
const auto rolled = UgcRender::RenderIcon(model, options);
|
||||
EXPECT_GT(coverage(rolled, false), coverage(rolled, true) * 2); // standing up: taller than wide
|
||||
}
|
||||
|
||||
TEST(UgcJobs, AssemblyNifIsTheIconsModel) {
|
||||
// Two modules, each a triangle; the second stands on the first's CP_A1 node
|
||||
const auto res = TempFolder("assembly");
|
||||
std::filesystem::create_directories(res / "mesh");
|
||||
UgcModel::Mesh triangle;
|
||||
triangle.positions = { { 0, 0, 0 }, { 1, 0, 0 }, { 0, 1, 0 } };
|
||||
triangle.normals = { { 0, 0, 1 }, { 0, 0, 1 }, { 0, 0, 1 } };
|
||||
triangle.colors = { { 1, 0, 0, 1 }, { 1, 0, 0, 1 }, { 1, 0, 0, 1 } };
|
||||
triangle.indices = { 0, 1, 2 };
|
||||
std::ofstream(res / "mesh" / "a.nif", std::ios::binary) << UgcFormats::WriteNif("A", { { "A", &triangle, false } });
|
||||
std::ofstream(res / "mesh" / "b.nif", std::ios::binary) << UgcFormats::WriteNif("B", { { "B", &triangle, false } });
|
||||
UgcJobs::ModularInput input;
|
||||
input.buildXml = R"(<ModularBuild><topology><numberOfParts value="2" /><rootPart value="0" /><connection myPartid="0" myLocation="CP_A1" connectingPart="1" /></topology>
|
||||
<Placement><AdditionalModelRotation><Rotation w="0.70710678" x="0" y="0.70710678" z="0" /></AdditionalModelRotation></Placement></ModularBuild>)";
|
||||
input.modules = { { 1, 0, "mesh/a.nif", "" }, { 2, 1, "mesh/b.nif", R"(<ModuleInfo><connection name="CP_A1"><translation x="0" y="0" z="0" /></connection></ModuleInfo>)" } };
|
||||
input.key = "1-2";
|
||||
std::string error, note;
|
||||
glm::mat4 additional{ 1.0f };
|
||||
const auto model = UgcJobs::AssembleModular(input, res, additional, error, note);
|
||||
ASSERT_TRUE(model) << error;
|
||||
EXPECT_EQ(model->opaque.TriangleCount(), 2u);
|
||||
const auto nif = UgcJobs::AssemblyNif(input, res, error);
|
||||
ASSERT_TRUE(nif) << error;
|
||||
const auto read = NifFile::Parse(*nif, 0, error);
|
||||
ASSERT_TRUE(read) << error;
|
||||
// The .nif holds the model already turned by the build type's AdditionalModelRotation (90 degrees around Y: +X -> -Z)
|
||||
const auto fromNif = UgcModel::FromNif(*read);
|
||||
ASSERT_EQ(fromNif.opaque.positions.size(), 6u);
|
||||
EXPECT_NEAR(fromNif.opaque.positions[1].z, -1.0f, 1e-4f);
|
||||
EXPECT_NEAR(fromNif.opaque.positions[1].x, 0.0f, 1e-4f);
|
||||
// And drawing it with no further turn gives the same icon as the renderer's own path
|
||||
UgcRender::IconOptions options{ 32, 1 };
|
||||
auto turned = options;
|
||||
turned.modelRotation = additional;
|
||||
EXPECT_EQ(UgcRender::RenderIcon(fromNif, options).rgba, UgcRender::RenderIcon(*model, turned).rgba);
|
||||
// Nothing to draw
|
||||
input.modules.clear();
|
||||
EXPECT_FALSE(UgcJobs::AssemblyNif(input, res, error));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user