From a5deacfc54a79b6b2414f749fda20e15a118e388 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 15:20:24 -0500 Subject: [PATCH] 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 --- dUgcServer/CMakeLists.txt | 1 + dUgcServer/UgcIconParams.cpp | 3 + dUgcServer/UgcIconPose.cpp | 99 +++++++++++++++++++++ dUgcServer/UgcIconPose.h | 56 ++++++++++++ dUgcServer/UgcJobs.cpp | 55 ++++++++---- dUgcServer/UgcJobs.h | 8 ++ dUgcServer/UgcProcessor.cpp | 72 +++++++++++++++- dUgcServer/UgcProcessor.h | 16 ++++ dUgcServer/UgcRender.cpp | 45 +++------- dUgcServer/UgcRender.h | 5 +- dUgcServer/UgcServer.cpp | 15 ++++ resources/ugcconfig.ini | 1 + tests/dUgcTests/UgcTests.cpp | 161 +++++++++++++++++++++++++++++++++++ 13 files changed, 487 insertions(+), 50 deletions(-) create mode 100644 dUgcServer/UgcIconPose.cpp create mode 100644 dUgcServer/UgcIconPose.h diff --git a/dUgcServer/CMakeLists.txt b/dUgcServer/CMakeLists.txt index 5f67721b3..329fe63d8 100644 --- a/dUgcServer/CMakeLists.txt +++ b/dUgcServer/CMakeLists.txt @@ -3,6 +3,7 @@ set(DUGC_SOURCES "UgcBricks.cpp" "UgcFormats.cpp" "UgcIconParams.cpp" + "UgcIconPose.cpp" "UgcJobs.cpp" "UgcModel.cpp" "UgcModular.cpp" diff --git a/dUgcServer/UgcIconParams.cpp b/dUgcServer/UgcIconParams.cpp index 4def9619d..6bfc83181 100644 --- a/dUgcServer/UgcIconParams.cpp +++ b/dUgcServer/UgcIconParams.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; } }, diff --git a/dUgcServer/UgcIconPose.cpp b/dUgcServer/UgcIconPose.cpp new file mode 100644 index 000000000..b8ccb0671 --- /dev/null +++ b/dUgcServer/UgcIconPose.cpp @@ -0,0 +1,99 @@ +#include "UgcIconPose.h" + +#include +#include +#include + +#include + +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*>& positions, const Camera& camera) { + constexpr float INF = std::numeric_limits::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; + } +} diff --git a/dUgcServer/UgcIconPose.h b/dUgcServer/UgcIconPose.h new file mode 100644 index 000000000..2c949f250 --- /dev/null +++ b/dUgcServer/UgcIconPose.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include + +/** + * 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*>& positions, const Camera& camera); +} diff --git a/dUgcServer/UgcJobs.cpp b/dUgcServer/UgcJobs.cpp index 647e1acb3..55ef61e49 100644 --- a/dUgcServer/UgcJobs.cpp +++ b/dUgcServer/UgcJobs.cpp @@ -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 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 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 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 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); diff --git a/dUgcServer/UgcJobs.h b/dUgcServer/UgcJobs.h index 8a9e2246e..7b202b26d 100644 --- a/dUgcServer/UgcJobs.h +++ b/dUgcServer/UgcJobs.h @@ -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 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 AssemblyNif(const ModularInput& input, const std::filesystem::path& res, std::string& error); } diff --git a/dUgcServer/UgcProcessor.cpp b/dUgcServer/UgcProcessor.cpp index aff1c1e49..217215fca 100644 --- a/dUgcServer/UgcProcessor.cpp +++ b/dUgcServer/UgcProcessor.cpp @@ -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(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 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 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 jobs; UgcJobs::Settings settings; diff --git a/dUgcServer/UgcProcessor.h b/dUgcServer/UgcProcessor.h index a231ec4c2..9eab69cba 100644 --- a/dUgcServer/UgcProcessor.h +++ b/dUgcServer/UgcProcessor.h @@ -5,7 +5,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -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" 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 m_Log; // the last results + + // Assembled meshes (QueueAssembly) by combination, newest first; workers add to it + std::mutex m_AssemblyMutex; + std::list>> m_Assemblies; + std::shared_ptr CachedAssembly(const std::string& key); + void CacheAssembly(const std::string& key, std::shared_ptr nif); }; diff --git a/dUgcServer/UgcRender.cpp b/dUgcServer/UgcRender.cpp index 443278c55..1258f4aa0 100644 --- a/dUgcServer/UgcRender.cpp +++ b/dUgcServer/UgcRender.cpp @@ -8,6 +8,7 @@ #include +#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 diff --git a/dUgcServer/UgcRender.h b/dUgcServer/UgcRender.h index f1d4b389b..ecd2cbd71 100644 --- a/dUgcServer/UgcRender.h +++ b/dUgcServer/UgcRender.h @@ -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 }; diff --git a/dUgcServer/UgcServer.cpp b/dUgcServer/UgcServer.cpp index 82a7e3ebd..6d65888f4 100644 --- a/dUgcServer/UgcServer.cpp +++ b/dUgcServer/UgcServer.cpp @@ -327,6 +327,21 @@ namespace { } } }); + Game::web.RegisterHTTPRoute({ .path = "/admin/assembly", .method = eHTTPMethod::POST, .middleware = {}, + .handle = [admin](HTTPReply& reply, const HTTPContext& context) { + std::optional 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 body; diff --git a/resources/ugcconfig.ini b/resources/ugcconfig.ini index 01f6b638d..74fa5c1d7 100644 --- a/resources/ugcconfig.ini +++ b/resources/ugcconfig.ini @@ -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 diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index fb5e1b9a6..f7642b96d 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -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(), 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 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"( + + + + + )", 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"( + )"; + input.modules = { { 1, 0, "mesh/a.nif", "" }, { 2, 1, "mesh/b.nif", R"()" } }; + 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)); +}