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