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:
Aaron Kimbrell
2026-09-27 15:20:24 -05:00
parent ae03a91fa5
commit a5deacfc54
13 changed files with 487 additions and 50 deletions

View File

@@ -3,6 +3,7 @@ set(DUGC_SOURCES
"UgcBricks.cpp"
"UgcFormats.cpp"
"UgcIconParams.cpp"
"UgcIconPose.cpp"
"UgcJobs.cpp"
"UgcModel.cpp"
"UgcModular.cpp"

View File

@@ -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; } },

View 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
View 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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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

View File

@@ -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));
}