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:
Aaron Kimbrell
2026-09-27 14:55:27 -05:00
parent 5de59b99ef
commit 6fd3114a0f
5 changed files with 80 additions and 66 deletions

View File

@@ -223,6 +223,13 @@ namespace {
return std::nullopt; 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 Counts(const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
nlohmann::json out = nlohmann::json::object(); nlohmann::json out = nlohmann::json::object();
for (const auto state : magic_enum::enum_values<IUgc::eProcessState>()) out[IUgc::ProcessStateName(state)] = 0; 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); const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); 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 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"); if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
const auto after = body->value("after", std::string("now")); 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 + ")"); 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); const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool model = body->value("kind", std::string()) == "model"; 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()))) } }); { "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); target = UgcIconParams::CombinationTarget(key);
what = "the icon of the module combination " + key; what = "the icon of the module combination " + key;
} else { } 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"); if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
target = UgcIconParams::ModelTarget(*id); target = UgcIconParams::ModelTarget(*id);
what = "the icon of model " + std::to_string(*id); what = "the icon of model " + std::to_string(*id);
@@ -500,7 +507,7 @@ namespace UgcRoutes {
const bool modular = body->value("kind", "model") == "modular"; const bool modular = body->value("kind", "model") == "modular";
std::optional<LWOOBJID> id; std::optional<LWOOBJID> id;
if (body->contains("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"); if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
} }
const bool failedOnly = body->value("failedOnly", false); const bool failedOnly = body->value("failedOnly", false);

View File

@@ -195,8 +195,8 @@
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); }); ['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 || '') + $('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>'; '</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); $('framingCard').classList.toggle('d-none', !canManage);
if (!model && canManage) openFraming(item); if (canManage) openFraming(item, model);
bootstrap.Modal.getOrCreateInstance($('previewModal')).show(); bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (!model) return; if (!model) return;
@@ -320,55 +320,67 @@
$('purgeButton').addEventListener('click', function () { purge(false); }); $('purgeButton').addEventListener('click', function () { purge(false); });
$('purgeAllButton').addEventListener('click', function () { purge(true); }); $('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 = [ var PARAMS = [], framingItem = null, framingKind = '', previewTimer = null, previewUrl = null;
['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], function stepDigits(step) { return step >= 1 ? 0 : 2; }
['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] function buildControls(params) {
]; PARAMS = params;
var framingItem = null, typeFraming = {}, previewTimer = null, previewUrl = null; $('framingControls').innerHTML = params.map(function (p) {
$('framingControls').innerHTML = FRAMING.map(function (f) { 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) +
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>' + '</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>';
'<input type="range" class="form-range" id="framing_' + f[0] + '" min="' + f[2] + '" max="' + f[3] + '" step="' + f[4] + '"></div>'; }).join('');
}).join(''); }
function framingValues() { function framingValues() {
var out = {}; 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; return out;
} }
function setFraming(values) { function setValues(values) {
FRAMING.forEach(function (f) { if (!values) return;
if (values[f[0]] === undefined || values[f[0]] === null) return; PARAMS.forEach(function (p) {
$('framing_' + f[0]).value = values[f[0]]; if (values[p.key] === undefined || values[p.key] === null) return;
$('framing_' + f[0] + '_value').textContent = (+values[f[0]]).toFixed(f[4] < 1 ? 2 : 0); $('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) { function itemQuery() {
if (!framingItem) return; return framingItem.model ? 'kind=model&id=' + encodeURIComponent(framingItem.id) : 'kind=modular&modules=' + encodeURIComponent(framingItem.modules || '');
var type = $('framingType').value; }
return api.get('/api/ugc/framing?buildType=' + type + '&modules=' + encodeURIComponent(framingItem.modules || '')).then(function (d) { function itemBody(extra) {
if (!d.success) return; var body = framingItem.model ? { kind: 'model', id: framingItem.id } : { kind: 'modular', modules: framingItem.modules };
typeFraming = d.type; for (var k in extra) body[k] = extra[k];
setFraming(d.type); return body;
if (useCombination && d.combination) setFraming(d.combination); }
$('framingState').textContent = d.combination && useCombination ? 'This combination has its own framing.' : 'The ' + d.name + ' framing.'; 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(); renderPreview();
}); });
} }
function openFraming(item) { function openFraming(item, model) {
framingItem = item; framingItem = { id: item.id, modules: item.modules, model: model };
// The build type from what the UGC server stored with the icon, when it has var start = PARAMS.length ? Promise.resolve() : api.get('/api/ugc/icon/params').then(function (d) {
fetchJson(fileUrl('modular', item.id, 'combo.json')).then(function (combo) { if (!d.success) return;
if (combo && (combo.buildType === 3 || combo.buildType === 6)) $('framingType').value = String(combo.buildType); buildControls(d.params);
loadFramingType(true); 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() { function renderPreview() {
if (!framingItem) return; if (!framingItem) return;
var img = $('framingPreview'); var img = $('framingPreview');
img.style.opacity = 0.5; img.style.opacity = 0.5;
fetch('/api/ugc/framing/preview', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, fetch('/api/ugc/icon/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) { 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); }); if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'HTTP ' + r.status); });
return r.blob(); return r.blob();
}).then(function (blob) { }).then(function (blob) {
@@ -378,37 +390,32 @@
img.style.opacity = 1; img.style.opacity = 1;
}).catch(function (e) { img.style.opacity = 1; $('framingState').textContent = 'No preview: ' + e.message; }); }).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) { $('framingControls').addEventListener('input', function (e) {
var f = FRAMING.find(function (x) { return 'framing_' + x[0] === e.target.id; }); var p = PARAMS.find(function (x) { return 'icon_' + x.key === e.target.id; });
if (f) $('framing_' + f[0] + '_value').textContent = (+e.target.value).toFixed(f[4] < 1 ? 2 : 0); if (p) $('icon_' + p.key + '_value').textContent = (+e.target.value).toFixed(stepDigits(p.step)) + (p.unit === 'degrees' ? '\u00b0' : '');
clearTimeout(previewTimer); clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 350); previewTimer = setTimeout(renderPreview, 350);
}); });
$('framingType').addEventListener('change', function () { loadFramingType(false); });
$('framingPreviewButton').addEventListener('click', renderPreview); $('framingPreviewButton').addEventListener('click', renderPreview);
$('framingReset').addEventListener('click', function () { $('framingReset').addEventListener('click', function () {
if (!framingItem) return; if (!framingItem || !confirm('Remove this item\'s own values and go back to its kind\'s?')) 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/icon/save', itemBody({ scope: 'item', values: null })).then(function (d) { saved(d); loadValues(); });
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: null }).then(function () { loadFramingType(false); });
}); });
$('framingSaveType').addEventListener('click', function () { $('framingSaveType').addEventListener('click', function () {
var name = $('framingType').selectedOptions[0].textContent.toLowerCase(); if (!confirm('Use these values for every icon of this kind? Icons already made keep theirs until they are drawn again.')) return;
if (!confirm('Use this framing for every ' + name + ' icon? 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);
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');
});
}); });
$('framingSaveCombo').addEventListener('click', function () { $('framingSaveCombo').addEventListener('click', function () {
if (!framingItem) return; if (!framingItem) return;
api.post('/api/ugc/framing/save', { scope: 'combination', modules: framingItem.modules, framing: framingValues() }).then(function (d) { api.post('/api/ugc/icon/save', itemBody({ scope: 'item', values: framingValues() })).then(function (d) {
toast(d.success ? d.message : (d.error || 'Failed'), d.success ? 'success' : 'danger'); saved(d);
if (d.success) $('framingState').textContent = 'This combination has its own framing.'; if (d.success) $('framingState').textContent = 'This item has its own values.';
}); });
}); });
$('framingRegenerate').addEventListener('click', function () { $('framingRegenerate').addEventListener('click', function () {
var name = $('framingType').selectedOptions[0].textContent.toLowerCase(); if (!confirm('Draw every stored icon of this kind again with the saved values? (Only icons are drawn.)')) return;
if (!confirm('Draw every stored ' + name + ' icon again with the saved framings?')) return; api.post('/api/ugc/icon/regenerate', { kind: framingKind }).then(function (d) {
api.post('/api/ugc/framing/regenerate', { buildType: +$('framingType').value }).then(function (d) {
toast(d.success ? d.queued + ' icon' + (d.queued === 1 ? '' : 's') + ' queued' : (d.error || 'Failed'), d.success ? 'success' : 'danger'); toast(d.success ? d.queued + ' icon' + (d.queued === 1 ? '' : 's') + ' queued' : (d.error || 'Failed'), d.success ? 'success' : 'danger');
}); });
}); });

View File

@@ -109,8 +109,8 @@
</div> </div>
<div class="mt-3" id="statsBox"></div> <div class="mt-3" id="statsBox"></div>
<div class="card mt-3 d-none" id="framingCard"> <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 <div class="card-header py-2 small fw-semibold d-flex align-items-center gap-2">Icon framing and light
<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> <span class="ms-auto text-body-secondary fw-normal" id="framingKind"></span></div>
<div class="card-body py-2 small"> <div class="card-body py-2 small">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-8"><div class="row g-2" id="framingControls"></div></div> <div class="col-md-8"><div class="row g-2" id="framingControls"></div></div>
@@ -119,10 +119,10 @@
</div> </div>
<div class="d-flex flex-wrap gap-2 mt-2"> <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-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="framingSaveType">Save as the preset for this kind</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="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-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> </div>
</div> </div>

View File

@@ -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; } }, { "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; } }, { "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_strength", "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; } },
{ "ambient", "icon_ambient", "World light", "", 0, 2, 0.01f, 1.0f, "What every face gets.", [](Options& o, float v) { o.ambient = 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; } }, { "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; } }, { "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; } }, { "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; } }, { "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; } }, { "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, { "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; } }, "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; } },
}; };

View File

@@ -83,8 +83,8 @@ 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_sun_yaw, icon_sun_pitch, icon_sun_strength, icon_ambient, 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_shadows, 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
# (UgcIconParams) and on the dashboard's settings page; set one here only to change it. The dashboard's icon editor # (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. # also keeps presets for player models and each car or rocket type, and overrides for single items, in the database.