mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): icon editor for any item, built from the parameter list
The editor on the UGC page works for player models as well as cars and rockets: its sliders come from /api/ugc/icon/params (UgcIconParams), it shows the item's kind (player models, or the build type named from the client's data), previews with the UGC server, and saves the kind's preset or the item's own values, resets them and draws a kind's icons again. The light settings whose defaults changed have new names (icon_world_light, icon_sun_light, icon_shadow_strength), so the darker values in existing ugcconfig.ini files no longer apply. Request ids are read safely (a missing id crashed the dashboard). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -223,6 +223,13 @@ namespace {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// An id from a request body, as a string or a number; nullopt when it's missing or not one
|
||||
std::optional<LWOOBJID> BodyId(const nlohmann::json& body) {
|
||||
const auto it = body.find("id");
|
||||
if (it == body.end()) return std::nullopt;
|
||||
return GeneralUtils::TryParse<LWOOBJID>(it->is_string() ? it->get<std::string>() : it->dump());
|
||||
}
|
||||
|
||||
nlohmann::json Counts(const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
|
||||
nlohmann::json out = nlohmann::json::object();
|
||||
for (const auto state : magic_enum::enum_values<IUgc::eProcessState>()) out[IUgc::ProcessStateName(state)] = 0;
|
||||
@@ -348,7 +355,7 @@ namespace UgcRoutes {
|
||||
const auto body = ParseBody(context);
|
||||
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
const auto kind = body->value("kind", std::string("model")) == "modular" ? std::string("modular") : std::string("model");
|
||||
const auto id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
|
||||
const auto id = BodyId(*body);
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
|
||||
const auto after = body->value("after", std::string("now"));
|
||||
Audit(context, "ugc_delete_files", "Deleted the UGC files of " + kind + " " + std::to_string(*id) + " (" + after + ")");
|
||||
@@ -439,7 +446,7 @@ namespace UgcRoutes {
|
||||
const auto body = ParseBody(context);
|
||||
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
const bool model = body->value("kind", std::string()) == "model";
|
||||
ProxyAdmin(reply, context, "/admin/preview", { { "kind", model ? "model" : "modular" }, { "id", (*body)["id"].is_string() ? (*body)["id"].get<std::string>() : std::string("0") },
|
||||
ProxyAdmin(reply, context, "/admin/preview", { { "kind", model ? "model" : "modular" }, { "id", std::to_string(BodyId(*body).value_or(0)) },
|
||||
{ "modules", body->value("modules", std::string()) }, { "values", nlohmann::json::parse(UgcIconParams::ToJson(UgcIconParams::Parse(body->value("values", nlohmann::json::object()).dump()))) } });
|
||||
});
|
||||
|
||||
@@ -463,7 +470,7 @@ namespace UgcRoutes {
|
||||
target = UgcIconParams::CombinationTarget(key);
|
||||
what = "the icon of the module combination " + key;
|
||||
} else {
|
||||
const auto id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
|
||||
const auto id = BodyId(*body);
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
|
||||
target = UgcIconParams::ModelTarget(*id);
|
||||
what = "the icon of model " + std::to_string(*id);
|
||||
@@ -500,7 +507,7 @@ namespace UgcRoutes {
|
||||
const bool modular = body->value("kind", "model") == "modular";
|
||||
std::optional<LWOOBJID> id;
|
||||
if (body->contains("id")) {
|
||||
id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
|
||||
id = BodyId(*body);
|
||||
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
|
||||
}
|
||||
const bool failedOnly = body->value("failedOnly", false);
|
||||
|
||||
@@ -195,8 +195,8 @@
|
||||
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); });
|
||||
$('statsBox').innerHTML = model ? '' : '<div class="small">Modules: <code>' + esc(item.modules || '') + '</code> (combination <code>' + esc(item.combination || '') +
|
||||
'</code>). The game client puts cars and rockets together itself; only the icon is made, once per combination of modules.</div>';
|
||||
$('framingCard').classList.toggle('d-none', model || !canManage);
|
||||
if (!model && canManage) openFraming(item);
|
||||
$('framingCard').classList.toggle('d-none', !canManage);
|
||||
if (canManage) openFraming(item, model);
|
||||
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
|
||||
if (!model) return;
|
||||
|
||||
@@ -320,55 +320,67 @@
|
||||
$('purgeButton').addEventListener('click', function () { purge(false); });
|
||||
$('purgeAllButton').addEventListener('click', function () { purge(true); });
|
||||
|
||||
// ---- car and rocket icon framing ----
|
||||
// ---- icon framing and light, for any item; the controls come from the server's list of parameters ----
|
||||
|
||||
var FRAMING = [
|
||||
['yaw', 'Angle around', -180, 180, 1], ['pitch', 'Angle above', -89, 89, 1], ['fov', 'Field of view', 5, 90, 0.5], ['margin', 'Border (zoom)', 0.5, 3, 0.01],
|
||||
['offsetX', 'Shift right', -0.5, 0.5, 0.01], ['offsetY', 'Shift up', -0.5, 0.5, 0.01], ['sunYaw', 'Sun around', -180, 180, 1], ['sunPitch', 'Sun above', -10, 90, 1]
|
||||
];
|
||||
var framingItem = null, typeFraming = {}, previewTimer = null, previewUrl = null;
|
||||
$('framingControls').innerHTML = FRAMING.map(function (f) {
|
||||
return '<div class="col-sm-6"><label class="d-flex justify-content-between" for="framing_' + f[0] + '"><span>' + esc(f[1]) + '</span><span id="framing_' + f[0] + '_value"></span></label>' +
|
||||
'<input type="range" class="form-range" id="framing_' + f[0] + '" min="' + f[2] + '" max="' + f[3] + '" step="' + f[4] + '"></div>';
|
||||
var PARAMS = [], framingItem = null, framingKind = '', previewTimer = null, previewUrl = null;
|
||||
function stepDigits(step) { return step >= 1 ? 0 : 2; }
|
||||
function buildControls(params) {
|
||||
PARAMS = params;
|
||||
$('framingControls').innerHTML = params.map(function (p) {
|
||||
return '<div class="col-sm-6"><label class="d-flex justify-content-between" for="icon_' + esc(p.key) + '" title="' + esc(p.description || '') + '"><span>' + esc(p.label) +
|
||||
'</span><span id="icon_' + esc(p.key) + '_value"></span></label><input type="range" class="form-range" id="icon_' + esc(p.key) + '" min="' + p.min + '" max="' + p.max + '" step="' + p.step + '"></div>';
|
||||
}).join('');
|
||||
}
|
||||
function framingValues() {
|
||||
var out = {};
|
||||
FRAMING.forEach(function (f) { out[f[0]] = parseFloat($('framing_' + f[0]).value); });
|
||||
PARAMS.forEach(function (p) { out[p.key] = parseFloat($('icon_' + p.key).value); });
|
||||
return out;
|
||||
}
|
||||
function setFraming(values) {
|
||||
FRAMING.forEach(function (f) {
|
||||
if (values[f[0]] === undefined || values[f[0]] === null) return;
|
||||
$('framing_' + f[0]).value = values[f[0]];
|
||||
$('framing_' + f[0] + '_value').textContent = (+values[f[0]]).toFixed(f[4] < 1 ? 2 : 0);
|
||||
function setValues(values) {
|
||||
if (!values) return;
|
||||
PARAMS.forEach(function (p) {
|
||||
if (values[p.key] === undefined || values[p.key] === null) return;
|
||||
$('icon_' + p.key).value = values[p.key];
|
||||
$('icon_' + p.key + '_value').textContent = (+values[p.key]).toFixed(stepDigits(p.step)) + (p.unit === 'degrees' ? '\u00b0' : '');
|
||||
});
|
||||
}
|
||||
function loadFramingType(useCombination) {
|
||||
if (!framingItem) return;
|
||||
var type = $('framingType').value;
|
||||
return api.get('/api/ugc/framing?buildType=' + type + '&modules=' + encodeURIComponent(framingItem.modules || '')).then(function (d) {
|
||||
if (!d.success) return;
|
||||
typeFraming = d.type;
|
||||
setFraming(d.type);
|
||||
if (useCombination && d.combination) setFraming(d.combination);
|
||||
$('framingState').textContent = d.combination && useCombination ? 'This combination has its own framing.' : 'The ' + d.name + ' framing.';
|
||||
function itemQuery() {
|
||||
return framingItem.model ? 'kind=model&id=' + encodeURIComponent(framingItem.id) : 'kind=modular&modules=' + encodeURIComponent(framingItem.modules || '');
|
||||
}
|
||||
function itemBody(extra) {
|
||||
var body = framingItem.model ? { kind: 'model', id: framingItem.id } : { kind: 'modular', modules: framingItem.modules };
|
||||
for (var k in extra) body[k] = extra[k];
|
||||
return body;
|
||||
}
|
||||
function loadValues() {
|
||||
return api.get('/api/ugc/icon/settings?' + itemQuery()).then(function (d) {
|
||||
if (!d.success) { $('framingState').textContent = d.error || 'Failed'; return; }
|
||||
framingKind = d.kind;
|
||||
setValues(d.settings);
|
||||
setValues(d.preset);
|
||||
setValues(d.own);
|
||||
$('framingState').textContent = d.own ? 'This item has its own values.' : d.preset ? 'The kind\'s preset.' : 'The default settings.';
|
||||
renderPreview();
|
||||
});
|
||||
}
|
||||
function openFraming(item) {
|
||||
framingItem = item;
|
||||
// The build type from what the UGC server stored with the icon, when it has
|
||||
fetchJson(fileUrl('modular', item.id, 'combo.json')).then(function (combo) {
|
||||
if (combo && (combo.buildType === 3 || combo.buildType === 6)) $('framingType').value = String(combo.buildType);
|
||||
loadFramingType(true);
|
||||
function openFraming(item, model) {
|
||||
framingItem = { id: item.id, modules: item.modules, model: model };
|
||||
var start = PARAMS.length ? Promise.resolve() : api.get('/api/ugc/icon/params').then(function (d) {
|
||||
if (!d.success) return;
|
||||
buildControls(d.params);
|
||||
window.ugcIconKinds = d.kinds;
|
||||
});
|
||||
start.then(loadValues).then(function () {
|
||||
var label = (window.ugcIconKinds || []).find(function (k) { return k.kind === framingKind; });
|
||||
$('framingKind').textContent = label ? label.label : framingKind;
|
||||
});
|
||||
}
|
||||
function renderPreview() {
|
||||
if (!framingItem) return;
|
||||
var img = $('framingPreview');
|
||||
img.style.opacity = 0.5;
|
||||
fetch('/api/ugc/framing/preview', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify({ modules: framingItem.modules, framing: framingValues() }) }).then(function (r) {
|
||||
fetch('/api/ugc/icon/preview', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: JSON.stringify(itemBody({ values: framingValues() })) }).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'HTTP ' + r.status); });
|
||||
return r.blob();
|
||||
}).then(function (blob) {
|
||||
@@ -378,37 +390,32 @@
|
||||
img.style.opacity = 1;
|
||||
}).catch(function (e) { img.style.opacity = 1; $('framingState').textContent = 'No preview: ' + e.message; });
|
||||
}
|
||||
function saved(d) { toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger'); }
|
||||
$('framingControls').addEventListener('input', function (e) {
|
||||
var f = FRAMING.find(function (x) { return 'framing_' + x[0] === e.target.id; });
|
||||
if (f) $('framing_' + f[0] + '_value').textContent = (+e.target.value).toFixed(f[4] < 1 ? 2 : 0);
|
||||
var p = PARAMS.find(function (x) { return 'icon_' + x.key === e.target.id; });
|
||||
if (p) $('icon_' + p.key + '_value').textContent = (+e.target.value).toFixed(stepDigits(p.step)) + (p.unit === 'degrees' ? '\u00b0' : '');
|
||||
clearTimeout(previewTimer);
|
||||
previewTimer = setTimeout(renderPreview, 350);
|
||||
});
|
||||
$('framingType').addEventListener('change', function () { loadFramingType(false); });
|
||||
$('framingPreviewButton').addEventListener('click', renderPreview);
|
||||
$('framingReset').addEventListener('click', function () {
|
||||
if (!framingItem) return;
|
||||
if (!confirm('Go back to the type\'s framing? This combination\'s own framing, if it has one, is removed.')) return;
|
||||
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: null }).then(function () { loadFramingType(false); });
|
||||
if (!framingItem || !confirm('Remove this item\'s own values and go back to its kind\'s?')) return;
|
||||
api.post('/api/ugc/icon/save', itemBody({ scope: 'item', values: null })).then(function (d) { saved(d); loadValues(); });
|
||||
});
|
||||
$('framingSaveType').addEventListener('click', function () {
|
||||
var name = $('framingType').selectedOptions[0].textContent.toLowerCase();
|
||||
if (!confirm('Use this framing for every ' + name + ' icon? Icons already made keep theirs until they are drawn again.')) return;
|
||||
api.post('/api/ugc/framing/save', { scope: 'type', buildType: +$('framingType').value, framing: framingValues() }).then(function (d) {
|
||||
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger');
|
||||
});
|
||||
if (!confirm('Use these values for every icon of this kind? Icons already made keep theirs until they are drawn again.')) return;
|
||||
api.post('/api/ugc/icon/save', { scope: 'kind', kind: framingKind, values: framingValues() }).then(saved);
|
||||
});
|
||||
$('framingSaveCombo').addEventListener('click', function () {
|
||||
if (!framingItem) return;
|
||||
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: framingValues() }).then(function (d) {
|
||||
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger');
|
||||
if (d.success) $('framingState').textContent = 'This combination has its own framing.';
|
||||
api.post('/api/ugc/icon/save', itemBody({ scope: 'item', values: framingValues() })).then(function (d) {
|
||||
saved(d);
|
||||
if (d.success) $('framingState').textContent = 'This item has its own values.';
|
||||
});
|
||||
});
|
||||
$('framingRegenerate').addEventListener('click', function () {
|
||||
var name = $('framingType').selectedOptions[0].textContent.toLowerCase();
|
||||
if (!confirm('Draw every stored ' + name + ' icon again with the saved framings?')) return;
|
||||
api.post('/api/ugc/framing/regenerate', { buildType: +$('framingType').value }).then(function (d) {
|
||||
if (!confirm('Draw every stored icon of this kind again with the saved values? (Only icons are drawn.)')) return;
|
||||
api.post('/api/ugc/icon/regenerate', { kind: framingKind }).then(function (d) {
|
||||
toast(d.success ? d.queued + ' icon' + (d.queued === 1 ? '' : 's') + ' queued' : (d.error || 'Failed'), d.success ? 'success' : 'danger');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,8 +109,8 @@
|
||||
</div>
|
||||
<div class="mt-3" id="statsBox"></div>
|
||||
<div class="card mt-3 d-none" id="framingCard">
|
||||
<div class="card-header py-2 small fw-semibold d-flex align-items-center gap-2">Icon framing
|
||||
<select class="form-select form-select-sm w-auto ms-auto" id="framingType" aria-label="Build type"><option value="6">Car</option><option value="3">Rocket</option></select></div>
|
||||
<div class="card-header py-2 small fw-semibold d-flex align-items-center gap-2">Icon framing and light
|
||||
<span class="ms-auto text-body-secondary fw-normal" id="framingKind"></span></div>
|
||||
<div class="card-body py-2 small">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><div class="row g-2" id="framingControls"></div></div>
|
||||
@@ -119,10 +119,10 @@
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingPreviewButton">Preview</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="framingSaveType">Save as default for this type</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="framingSaveCombo">Save for this combination</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="framingSaveType">Save as the preset for this kind</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="framingSaveCombo">Save for this item</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingReset">Reset</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning ms-auto" id="framingRegenerate">Draw all icons of this type again</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning ms-auto" id="framingRegenerate">Draw all icons of this kind again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,14 +23,14 @@ namespace UgcIconParams {
|
||||
{ "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; } },
|
||||
{ "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_strength", "Sun strength", "", 0, 10, 0.05f, 2.0f, "", [](Options& o, float v) { o.sunStrength = v; } },
|
||||
{ "ambient", "icon_ambient", "World light", "", 0, 2, 0.01f, 1.0f, "What every face gets.", [](Options& o, float v) { o.ambient = v; } },
|
||||
{ "sunStrength", "icon_sun_light", "Sun strength", "", 0, 10, 0.05f, 2.0f, "", [](Options& o, float v) { o.sunStrength = v; } },
|
||||
{ "ambient", "icon_world_light", "World light", "", 0, 2, 0.01f, 1.0f, "What every face gets.", [](Options& o, float v) { o.ambient = v; } },
|
||||
{ "fill", "icon_fill", "Fill light", "", 0, 5, 0.05f, 0.8f, "A light from the camera.", [](Options& o, float v) { o.fill = v; } },
|
||||
{ "specular", "icon_specular", "Highlights", "", 0, 2, 0.05f, 0.2f, "The sun's highlight on the plastic.", [](Options& o, float v) { o.specular = v; } },
|
||||
{ "shininess", "icon_shininess", "Highlight tightness", "", 1, 200, 1, 40.0f, "", [](Options& o, float v) { o.shininess = v; } },
|
||||
{ "exposure", "icon_exposure", "Exposure", "", 0.1f, 4, 0.05f, 2.6f, "Brightness of everything.", [](Options& o, float v) { o.exposure = v; } },
|
||||
{ "contrast", "icon_contrast", "Contrast", "", 0.5f, 1.5f, 0.01f, 1.0f, "", [](Options& o, float v) { o.contrast = v; } },
|
||||
{ "shadows", "icon_shadows", "Shadows", "", 0, 1, 0.05f, 0.4f, "How much the sun's shadows darken.", [](Options& o, float v) { o.shadows = v; } },
|
||||
{ "shadows", "icon_shadow_strength", "Shadows", "", 0, 1, 0.05f, 0.4f, "How much the sun's shadows darken.", [](Options& o, float v) { o.shadows = v; } },
|
||||
{ "aoStrength", "icon_ao_strength", "Ambient occlusion", "", 0, 1, 0.05f, 0.0f,
|
||||
"Occlusion worked out for the icon. Player models' meshes have theirs baked in already.", [](Options& o, float v) { o.ao.strength = v; o.ao.enabled = v > 0.0f; } },
|
||||
};
|
||||
|
||||
@@ -83,8 +83,8 @@ 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_sun_yaw, icon_sun_pitch, icon_sun_strength, icon_ambient, icon_fill, icon_specular, icon_shininess,
|
||||
# icon_exposure, icon_contrast, icon_shadows, icon_ao_strength. Their defaults (the framing from LU Toolbox's icon
|
||||
# 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
|
||||
# (UgcIconParams) and on the dashboard's settings page; set one here only to change it. The dashboard's icon editor
|
||||
# also keeps presets for player models and each car or rocket type, and overrides for single items, in the database.
|
||||
|
||||
Reference in New Issue
Block a user