fix(dashboard): UGC page: Make again with… replaces the option pickers that looked like settings; a model's makes listed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 21:27:57 -05:00
parent a72d99f531
commit 9bcc132dde
3 changed files with 93 additions and 31 deletions

View File

@@ -128,11 +128,47 @@
// When it was made and what that cost (the cost is only known for makes since it was recorded), and with what
function madeText(i) {
if (!i.processedAt) return '';
return fmt.unix(i.processedAt) + (costText(i) ? ' \u00b7 ' + costText(i) : '') + (i.madeOptions ? ' \u00b7 with ' + i.madeOptions : '');
return fmt.unix(i.processedAt) + (costText(i) ? ' \u00b7 ' + costText(i) : '') + (i.madeOptions ? ' \u00b7 with ' + optionsText(i.madeOptions) : '');
}
// The processing options picked for Make again (ray tracer, denoise, processor), as /api/ugc/reprocess takes them
function remakeOptions() {
return ['optRays', 'optDenoise', 'optProcessor'].map(function (x) { return $(x).value; }).filter(Boolean).join(' ');
// ---- processing options (docs/UgcServer.md, "Processing options"): the settings pick what every make uses; "Make again with…" another for one make ----
var OPTION_LABELS = { embree: 'Embree (CPU)', hiprt: 'HIPRT (GPU)', 'embree-gpu': 'Embree (Intel GPU)', off: 'no denoise', oidn: 'Open Image Denoise',
native: 'UGC server', 'toolbox-blender': 'LU Toolbox (Blender)' };
var OPTION_GROUPS = [['rays', 'Ray tracer'], ['denoise', 'Denoise icons'], ['processor', 'Made by']];
var options = null; // /api/ugc/options: choices and the settings' defaults
// Stored options ("embree off", "hiprt oidn toolbox-blender") in words; no processor word: the UGC server's own
function optionsText(text) {
var words = String(text || '').split(/[ ,]+/).filter(function (w) { return w && w !== 'toolbox' && w !== 'fast'; });
if (!words.length) return '';
words = words.map(function (w) { return w === 'builtin' ? 'embree' : w; });
if (words.indexOf('toolbox-blender') < 0 && words.indexOf('native') < 0) words.push('native');
return words.map(function (w) { return OPTION_LABELS[w] || w; }).join(', ');
}
// A "Make again with…" dropdown: a form with each choice preset to the settings' default, for one make only
function withMenu(target, label) {
if (!options || !canManage) return '';
var selects = OPTION_GROUPS.filter(function (g) { return options.choices[g[0]]; }).map(function (g) {
return '<label class="d-block mb-1">' + esc(g[1]) + '<select class="form-select form-select-sm" data-option="' + g[0] + '">' + options.choices[g[0]].map(function (name) {
return '<option value="' + esc(name) + '"' + (name === options.defaults[g[0]] ? ' selected' : '') + '>' + esc(OPTION_LABELS[name] || name) +
(name === options.defaults[g[0]] ? ' (setting)' : '') + '</option>';
}).join('') + '</select></label>';
}).join('');
return '<div class="dropdown d-inline-block"><button type="button" class="btn btn-sm btn-outline-warning dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">' +
esc(label) + '</button><form class="dropdown-menu p-2 small" style="min-width:16rem" data-remake-with="' + esc(target) + '">' + selects +
'<div class="text-body-secondary my-1">For this make only; every other make uses the settings.</div><button type="submit" class="btn btn-sm btn-warning w-100">Make again</button></form></div>';
}
function withOptions(form) {
var chosen = [].map.call(form.querySelectorAll('[data-option]'), function (x) { return x.value; });
var defaults = OPTION_GROUPS.map(function (g) { return options.defaults[g[0]]; });
// The same as the settings: no options, so the make follows the settings
return chosen.join(' ') === defaults.join(' ') ? '' : chosen.join(' ');
}
function showOptions() {
var model = list.kind === 'model';
$('madeWith').innerHTML = options && model ? 'New makes: ' + esc(OPTION_GROUPS.map(function (g) { return OPTION_LABELS[options.defaults[g[0]]] || options.defaults[g[0]]; }).join(', ')) +
(window.DASH && DASH.can('settings') ? ' (<a href="#" data-goto-settings>change</a>)' : '') : '';
$('remakeAllWith').innerHTML = model ? withMenu('all', 'Make everything again with…') : '';
}
// A model's file as the player named it: its name with the upload's extension (".lxfml"), else the upload's name
function fileName(i) {
@@ -183,8 +219,8 @@
})
].concat(madeColumns(), [
column('Options', function (i) {
return '<span class="small">' + esc(i.madeOptions || '') + '</span>' +
(i.processOptions ? '<div class="small text-body-secondary" title="Picked for its next make">next: ' + esc(i.processOptions) + '</div>' : '');
return '<span class="small" title="' + esc(i.madeOptions || '') + '">' + esc(optionsText(i.madeOptions)) + '</span>' +
(i.processOptions ? '<div class="small text-body-secondary" title="Picked for its next make">next: ' + esc(optionsText(i.processOptions)) + '</div>' : '');
}),
column('Saved', function (i) {
if (!i.trianglesBefore) return '';
@@ -264,7 +300,7 @@
b.textContent = (b.dataset.kind === 'modular' ? 'Cars and rockets' : 'Models') + (count === null || count === undefined ? '' : ' (' + Number(count).toLocaleString() + ')');
});
$('manageButtons').classList.toggle('d-none', !canManage);
$('remakeOptions').classList.toggle('d-none', list.kind !== 'model');
showOptions();
$('cacheCard').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets (builds)', d.counts.modular) +
(d.totals ? totalsCard('Models', d.totals.model) + totalsCard('Cars and rockets', d.totals.modular) : '');
@@ -375,7 +411,6 @@
function remake(body, question) {
if (question && !confirm(question)) return;
body.kind = list.kind;
if (list.kind === 'model' && remakeOptions()) body.options = remakeOptions();
api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
@@ -385,6 +420,22 @@
// ---- the open item ----
// Every make of a model with what made it, to compare the options on one model
function loadRuns(id) {
api.get('/api/ugc/' + encodeURIComponent(id) + '/runs').then(function (d) {
var box = $('runsBox');
if (!box || !current || current.id !== id || !d.success) return;
var ms = function (v) { return v ? esc(duration(v)) : ''; };
box.innerHTML = '<div class="small fw-semibold mt-3 mb-1">Makes</div>' + (d.runs.length ? '<div class="table-responsive"><table class="table table-sm small align-middle mb-0">' +
'<thead><tr><th>Made</th><th>With</th><th>Took</th><th>CPU</th><th>Hidden faces</th><th>Occlusion</th><th>Icon</th><th>Triangles</th></tr></thead><tbody>' +
d.runs.map(function (r) {
return '<tr><td>' + esc(fmt.unix(r.madeAt)) + '</td><td title="' + esc(r.options) + '">' + esc(optionsText(r.options)) + '</td><td>' + ms(r.ms) + '</td><td>' + ms(r.cpuMs) +
'</td><td>' + ms(r.hsrMs) + '</td><td>' + ms(r.aoMs) + '</td><td>' + ms(r.iconMs) + '</td><td title="' + esc(r.trianglesBefore.toLocaleString()) + ' before">' +
esc(r.triangles.toLocaleString()) + '</td></tr>';
}).join('') + '</tbody></table></div>' : '<div class="small text-body-secondary">No make recorded (made before makes were recorded).</div>');
});
}
function statsTable(now, before) {
if (!now) return '<div class="small text-body-secondary">No stats for this model (made before the UGC server wrote them).</div>';
var lods = now.lods || [], old = before && before.lods || [];
@@ -468,7 +519,7 @@
}
if (publicUrl && current.fileId) links += '<a href="' + esc(publicUrl + '/files/' + list.kind + '/' + current.fileId + '/icon.png') + '" target="_blank" rel="noopener">Open on the UGC server</a>';
if (canManage && current.fileId) {
links += '<div class="mt-2 d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(current.fileId) + '">Make again</button>' +
links += '<div class="mt-2 d-flex flex-wrap gap-1"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(current.fileId) + '">Make again</button>' + (modular ? '' : withMenu(current.fileId, 'Make again with…')) +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="now">Delete files, make again</button>' +
'<button type="button" class="btn btn-sm btn-outline-danger" data-delete-open="gone">Delete files, leave deleted</button></div>';
}
@@ -491,7 +542,8 @@
if (!current || current.id !== id) return;
current.stats = s[0];
current.statsLoaded = true;
$('statsBox').innerHTML = statsTable(s[0], s[1]);
$('statsBox').innerHTML = statsTable(s[0], s[1]) + '<div id="runsBox"></div>';
loadRuns(id);
var lods = (s[0] && s[0].lods) || [{ lod: 0 }];
$('lodSelect').innerHTML = lods.map(function (l, n) { return '<option value="' + n + '">LOD ' + esc(l.lod) + (l.far ? ' (' + esc(l.near) + '-' + esc(l.far) + ')' : '') + '</option>'; }).join('');
$('versionSelect').querySelector('[value=previous]').disabled = !s[1];
@@ -629,6 +681,22 @@
$('listCard').addEventListener('click', onItemClick);
$('gallery').addEventListener('click', onItemClick);
$('gallery').addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(e); } });
document.addEventListener('submit', function (e) {
var form = e.target.closest('[data-remake-with]');
if (!form) return;
e.preventDefault();
var body = form.dataset.remakeWith === 'all' ? {} : { id: form.dataset.remakeWith }, picked = withOptions(form);
if (picked) body.options = picked;
var toggle = form.parentNode.querySelector('[data-bs-toggle]');
if (toggle && window.bootstrap) bootstrap.Dropdown.getOrCreateInstance(toggle).hide();
remake(body, form.dataset.remakeWith === 'all' ? 'Make every model again' + (picked ? ' with ' + optionsText(picked) : '') + '? This can take a long time.' : null);
});
$('madeWith').addEventListener('click', function (e) {
if (!e.target.closest('[data-goto-settings]')) return;
e.preventDefault();
var box = document.querySelector('[data-ugc-settings="UGC models"]');
if (box) box.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
$('previewLinks').addEventListener('click', function (e) {
var button = e.target.closest('[data-remake-open]');
if (button) remake({ id: button.dataset.remakeOpen });
@@ -726,21 +794,14 @@
// ---- processing options: Make again's choices and the comparison of what made the models ----
var OPTION_LABELS = { embree: 'Embree (CPU)', hiprt: 'HIPRT (GPU)', 'embree-gpu': 'Embree (Intel GPU)', off: 'Off', oidn: 'Open Image Denoise',
native: 'UGC server', 'toolbox-blender': 'LU Toolbox (Blender)' };
function loadOptions() {
return api.get('/api/ugc/options').then(function (d) {
if (!d.success) throw new Error(d.error || 'Failed');
[['optRays', 'rays', 'Ray tracer'], ['optDenoise', 'denoise', 'Denoise'], ['optProcessor', 'processor', 'Made by']].forEach(function (x) {
if (!d.choices[x[1]]) return;
var select = $(x[0]), chosen = select.value;
select.innerHTML = '<option value="">' + esc(x[2]) + ': settings (' + esc(OPTION_LABELS[d.defaults[x[1]]] || d.defaults[x[1]]) + ')</option>' +
d.choices[x[1]].map(function (name) { return '<option value="' + esc(name) + '">' + esc(x[2]) + ': ' + esc(OPTION_LABELS[name] || name) + '</option>'; }).join('');
select.value = chosen;
});
options = { choices: d.choices, defaults: d.defaults };
showOptions();
var ms = function (v) { return v ? esc(duration(v)) : ''; };
$('optionsTable').querySelector('tbody').innerHTML = d.combinations.length ? d.combinations.map(function (c) {
return '<tr><td><code>' + esc(c.options) + '</code></td><td>' + esc(c.runs.toLocaleString()) + '</td><td>' + esc(c.models.toLocaleString()) + '</td><td>' + ms(c.ms) +
return '<tr><td title="' + esc(c.options) + '">' + esc(optionsText(c.options)) + '</td><td>' + esc(c.runs.toLocaleString()) + '</td><td>' + esc(c.models.toLocaleString()) + '</td><td>' + ms(c.ms) +
'</td><td>' + ms(c.cpuMs) + '</td><td>' + ms(c.hsrMs) + '</td><td>' + ms(c.aoMs) + '</td><td>' + ms(c.iconMs) + '</td><td>' + esc(c.bricks) + '</td><td title="' +
esc(c.trianglesBefore.toLocaleString()) + ' before, ' + esc(c.triangles.toLocaleString()) + ' after, on average">' + esc((c.removed * 100).toFixed(1)) + '%</td></tr>';
}).join('') : '<tr><td colspan="10" class="text-body-secondary">No model made since the options were recorded.</td></tr>';
@@ -752,6 +813,8 @@
$('optionsCard').addEventListener('toggle', function () { if (this.open) loadOptions(); });
if (window.Live) Live.on('ugc', load);
// A changed ray tracer, denoise or processor setting changes what new makes use
if (window.Live) Live.on('settings', loadOptions);
var linked = readUrl();
writeUrl(false, linked);

View File

@@ -37,9 +37,10 @@
<div class="card-body py-2 small">
<p class="text-body-secondary mb-2">The ways of making models to compare: the ray tracer of the occlusion (Embree on the CPU, HIPRT on AMD or
NVIDIA GPUs, Embree on Intel GPUs), denoising of icons, and what makes the models (the UGC server itself, or LU Toolbox in Blender:
<code>toolbox-blender</code>, where its hidden faces are Process Model's and its occlusion Bake Lighting's). The settings pick the defaults; <b>Make again</b> can use others for a make (the options next
to it, or <code>/reprocessproperty embree oidn</code> in game).
Every make is counted under what made it: the averages per make below, each model's in the List view's Options column.</p>
<code>toolbox-blender</code>, where its hidden faces are Process Model's and its occlusion Bake Lighting's). The settings (<b>How models are made</b> above) pick what every make uses; <b>Make again with…</b> uses others for that one make only
(or <code>/reprocessproperty embree oidn</code> in game).
Every make is recorded with what made it: the averages per make below, each model's in the List view's Options column, and every make of a
model in its window under <b>Makes</b>.</p>
<div class="table-responsive"><table class="table table-sm align-middle mb-0" id="optionsTable">
<thead><tr><th>Options</th><th>Makes</th><th>Models</th><th>Took</th><th>CPU</th><th>Hidden faces</th><th>Occlusion</th><th>Icon</th><th>Bricks</th><th>Triangles removed</th></tr></thead>
<tbody><tr><td colspan="10" class="text-body-secondary">Loading…</td></tr></tbody></table></div>
@@ -82,13 +83,10 @@
<button type="button" class="btn btn-outline-secondary" data-view="list">List</button>
</div>
<div class="ms-auto d-none d-flex flex-wrap gap-1 align-items-center" id="manageButtons">
<span class="d-flex gap-1" id="remakeOptions" title="How Make again makes models (the settings' way unless picked here)">
<select class="form-select form-select-sm w-auto" id="optRays" aria-label="Ray tracer for Make again"><option value="">Ray tracer: settings</option></select>
<select class="form-select form-select-sm w-auto" id="optDenoise" aria-label="Denoise for Make again"><option value="">Denoise: settings</option></select>
<select class="form-select form-select-sm w-auto" id="optProcessor" aria-label="What makes the models for Make again"><option value="">Made by: settings</option></select>
</span>
<span class="small text-body-secondary" id="madeWith"></span>
<button type="button" class="btn btn-sm btn-outline-warning" id="retryFailed">Try the failed ones again</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="remakeAll">Make everything again</button>
<span id="remakeAllWith"></span>
</div>
</div>

View File

@@ -242,7 +242,7 @@ took 17 to 100 times as long (for example 45 s instead of 2.6 s for a 140-brick
### Processing options
Ways of making models to try and compare, none replacing another. The settings pick the defaults; staff can make
models again with others (the UGC page's options next to **Make again**, `/api/ugc/reprocess` with `options`,
models again with others for that make only (the UGC page's **Make again with…** on a model or on everything, `/api/ugc/reprocess` with `options`,
`/reprocessproperty [embree|hiprt|embree-gpu] [off|oidn] [native|toolbox-blender]` in game, `UgcServer --make-model
<file> <folder> [options]`). The choices are written as their names in any order (`UgcProcessOptions` in
`dCommon/UgcKeys.h`); one left out is the setting's. Options stored by earlier versions still read: `builtin` (the UGC
@@ -950,8 +950,9 @@ name; assemblies the same (most modules for most bricks, no triangles or saved)
search, filters, sort, page and view are kept in the address, so Back and Forward and shared links work. The search
is `UgcLookupSql` (the same on MySQL and SQLite, `IUgcLookup::ListUgc`); assemblies are grouped
from the builds (`UgcAssemblies`). Buttons make one item, the failed ones or everything again (these only reset the
columns; the UGC server picks the rows up), for models with the processing options picked next to them (see
"Processing options").
columns; the UGC server picks the rows up); for models, **Make again with…** picks other processing options for that
make only, and the toolbar says what new makes use (see "Processing options"). A model's window lists every make of it
(`ugc_process_runs`, `/api/ugc/:id/runs`) with what made it and its times, to compare the options on one model.
An assembly opens with its modules, the icon editor and **References**: the builds that use it (`GET
/api/ugc/assembly/builds?modules=&q=&page=`), with owner character and account, state and where each is (placed on a