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;