feat(dashboard): 3D icon pose editor on the UGC page

The icon editor shows a player model or a car or rocket's assembled modules
through the icon renderer's own camera (same perspective, framing and model
turn), with the icon's square outlined, a sun arrow and a debounced live
preview. Dragging moves the camera, turns the model, moves the sun or shifts
the icon; the wheel sets the border; every parameter's slider (grouped from
the parameter list) stays in step both ways. Save as the type's preset or the
item's own, go back to the preset or defaults, draw a type's icons again. A
presets section opens each type's editor on an example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 15:32:19 -05:00
parent 4f77b239e3
commit d0e6c96ead
4 changed files with 538 additions and 111 deletions

View File

@@ -0,0 +1,233 @@
/**
* The /ugc page's icon editor: one panel for everything an icon's look can be set to, built from the UGC server's one
* list of parameters (/api/ugc/icon/params): the 3D pose view (ugc-pose.js: camera, the model's turn, the sun, the
* shift and border, dragged or wheeled), the sliders of every parameter (camera, framing, model, sun, light, look) kept
* in step with it both ways, and a live preview drawn by the UGC server as things change. The values can be saved as
* the preset of the item's type or as the item's (or module combination's) own, and every icon of a type drawn again.
*/
(function () {
'use strict';
var GROUPS = [['camera', 'Camera'], ['framing', 'Border and shift'], ['model', 'Model turn'], ['sun', 'Sun'], ['light', 'Light'], ['look', 'Look']];
var PARAMS = [], KINDS = [], paramsLoaded = null;
var target = null; // {model, id, modules, label}
var layers = null; // {kind, settings, preset, own}
var values = {};
var editor = null, editorLoading = null, previewTimer = null, previewSequence = 0, previewUrl = null;
var canManage = window.DASH && DASH.can ? DASH.can('ugc_manage') : false;
function $(id) { return document.getElementById(id); }
function digits(step) { return step >= 1 ? 0 : step >= 0.1 ? 1 : 2; }
function shown(p, value) { return (+value).toFixed(digits(p.step)) + (p.unit === 'degrees' ? '°' : ''); }
function loadParams() {
if (!paramsLoaded) {
paramsLoaded = api.get('/api/ugc/icon/params').then(function (d) {
if (!d.success) throw new Error(d.error || 'No parameters');
PARAMS = d.params;
KINDS = d.kinds || [];
buildControls();
return d;
});
}
return paramsLoaded;
}
function buildControls() {
var known = GROUPS.map(function (g) { return g[0]; });
var groups = GROUPS.concat(PARAMS.filter(function (p) { return known.indexOf(p.group) === -1; })
.map(function (p) { return [p.group, p.group]; }).filter(function (g, i, all) { return all.findIndex(function (x) { return x[0] === g[0]; }) === i; }));
$('framingControls').innerHTML = groups.map(function (g) {
var list = PARAMS.filter(function (p) { return p.group === g[0]; });
if (!list.length) return '';
return '<div class="col-md-6 col-xl-4"><fieldset class="border rounded px-2 pb-1 h-100"><legend class="float-none w-auto px-1 fs-6 mb-0">' + esc(g[1]) + '</legend>' +
list.map(function (p) {
return '<label class="d-flex justify-content-between mb-0" for="icon_' + esc(p.key) + '" title="' + esc(p.description || '') + '"><span>' + esc(p.label) +
'</span><span id="icon_' + esc(p.key) + '_value" class="font-monospace"></span></label><input type="range" class="form-range" id="icon_' + esc(p.key) +
'" data-key="' + esc(p.key) + '" min="' + p.min + '" max="' + p.max + '" step="' + p.step + '">';
}).join('') + '</fieldset></div>';
}).join('');
}
// Sliders from `values` (only the keys given)
function showValues(keys) {
(keys || PARAMS.map(function (p) { return p.key; })).forEach(function (key) {
var p = PARAMS.find(function (x) { return x.key === key; });
if (!p || values[key] === undefined) return;
$('icon_' + key).value = values[key];
$('icon_' + key + '_value').textContent = shown(p, values[key]);
});
}
function layered(which) {
var out = {};
['settings', 'preset', 'own'].slice(0, which).forEach(function (layer) {
var v = layers && layers[layer];
if (v) for (var k in v) out[k] = v[k];
});
return out;
}
function setAll(next, why) {
values = next;
showValues();
if (editor) editor.setValues(values);
if (why) $('framingState').textContent = why;
schedulePreview(0);
}
function itemQuery() {
return target.model ? 'kind=model&id=' + encodeURIComponent(target.id) : 'kind=modular&modules=' + encodeURIComponent(target.modules || '');
}
function itemBody(extra) {
var body = target.model ? { kind: 'model', id: target.id } : { kind: 'modular', modules: target.modules };
for (var k in extra) body[k] = extra[k];
return body;
}
function kindLabel() {
var k = KINDS.find(function (x) { return x.kind === (layers && layers.kind); });
return k ? k.label : (layers ? layers.kind : '');
}
function loadLayers() {
return api.get('/api/ugc/icon/settings?' + itemQuery()).then(function (d) {
if (!d.success) throw new Error(d.error || 'No settings');
layers = { kind: d.kind, settings: d.settings, preset: d.preset, own: d.own };
$('framingKind').textContent = kindLabel();
setAll(layered(3), d.own ? 'This item has its own values.' : d.preset ? 'The type’s preset.' : 'The default settings.');
});
}
function ensureEditor() {
if (editor) return Promise.resolve(editor);
if (!editorLoading) {
editorLoading = import('/js/ugc-pose.js').then(function (module) {
var limits = {};
PARAMS.forEach(function (p) { limits[p.key] = { min: p.min, max: p.max }; });
editor = module.createPoseEditor($('poseView'), {
limits: limits,
onChange: function (changed) {
for (var k in changed) values[k] = changed[k];
showValues(Object.keys(changed));
$('framingState').textContent = 'Changed, not saved.';
schedulePreview(300);
}
});
editor.setSunArrow($('sunArrowSwitch').checked);
return editor;
});
}
return editorLoading;
}
function loadMesh() {
var url = target.model ? '/api/ugc/mesh/' + encodeURIComponent(target.id) + '?lod=0' : '/api/ugc/assembly?modules=' + encodeURIComponent(target.modules || '');
$('poseStats').textContent = 'Loading the ' + (target.model ? 'model' : 'assembled modules') + '…';
var wanted = target;
return ensureEditor().then(function (e) {
e.setValues(values);
return e.load(url);
}).then(function (r) {
if (target !== wanted) return;
$('poseStats').textContent = r.triangles.toLocaleString() + ' triangles' + (target.model ? ' (the made .nif, most detailed level)' : ' (the modules put together as the icon renderer does)') + '.';
}).catch(function (e) {
$('poseStats').textContent = 'The 3D view could not load: ' + e.message;
});
}
function schedulePreview(delay) {
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, delay);
}
function renderPreview() {
if (!target) return;
var img = $('framingPreview'), sequence = ++previewSequence;
img.style.opacity = 0.5;
fetch('/api/ugc/icon/preview', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dashboard' },
body: JSON.stringify(itemBody({ values: values })) }).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) {
if (sequence !== previewSequence) return; // a newer one is on its way
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
img.src = previewUrl;
img.style.opacity = 1;
}).catch(function (e) {
if (sequence !== previewSequence) return;
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'); }
// ---- the controls ----
$('framingControls').addEventListener('input', function (e) {
var key = e.target.dataset && e.target.dataset.key;
if (!key) return;
values[key] = parseFloat(e.target.value);
showValues([key]);
if (editor) editor.setValues(values);
$('framingState').textContent = 'Changed, not saved.';
schedulePreview(300);
});
$('poseModes').addEventListener('click', function (e) {
var button = e.target.closest('[data-mode]');
if (!button) return;
this.querySelectorAll('[data-mode]').forEach(function (b) { b.classList.toggle('active', b === button); });
if (editor) editor.setMode(button.dataset.mode);
});
$('sunArrowSwitch').addEventListener('change', function () { if (editor) editor.setSunArrow(this.checked); });
$('framingPreviewButton').addEventListener('click', renderPreview);
$('framingResetType').addEventListener('click', function () { setAll(layered(2), 'The type’s preset (not saved for this item).'); });
$('framingResetDefaults').addEventListener('click', function () { setAll(layered(1), 'The default settings (not saved).'); });
$('framingReset').addEventListener('click', function () {
if (!target || !confirm('Remove this item’s own values and go back to its type’s?')) return;
api.post('/api/ugc/icon/save', itemBody({ scope: 'item', values: null })).then(function (d) { saved(d); loadLayers(); });
});
$('framingSaveType').addEventListener('click', function () {
if (!layers || !confirm('Use these values for every icon of the type "' + kindLabel() + '"? Icons already made keep theirs until they are drawn again.')) return;
api.post('/api/ugc/icon/save', { scope: 'kind', kind: layers.kind, values: values }).then(function (d) {
saved(d);
if (d.success) layers.preset = Object.assign({}, values);
});
});
$('framingSaveCombo').addEventListener('click', function () {
if (!target) return;
api.post('/api/ugc/icon/save', itemBody({ scope: 'item', values: values })).then(function (d) {
saved(d);
if (d.success) {
layers.own = Object.assign({}, values);
$('framingState').textContent = 'This item has its own values.';
}
});
});
$('framingRegenerate').addEventListener('click', function () {
if (!layers || !confirm('Draw every stored icon of the type "' + kindLabel() + '" again with the saved values? (Only icons are drawn.)')) return;
api.post('/api/ugc/icon/regenerate', { kind: layers.kind }).then(function (d) {
toast(d.success ? d.queued + ' icon' + (d.queued === 1 ? '' : 's') + ' queued' : (d.error || 'Failed'), d.success ? 'success' : 'danger');
});
});
document.querySelectorAll('#framingCard .ugc-manage').forEach(function (b) { b.classList.toggle('d-none', !canManage); });
window.UgcIconEditor = {
/** Opens the editor on an item: {model: true, id} or {model: false, modules, label} */
open: function (item) {
target = item;
$('framingCard').classList.remove('d-none');
$('framingSaveCombo').textContent = item.model ? 'Save for this model' : 'Save for this combination of modules';
return loadParams().then(loadLayers).then(loadMesh).catch(function (e) { $('framingState').textContent = e.message; });
},
close: function () {
target = null;
clearTimeout(previewTimer);
},
/** The kinds of icon (player models, each car or rocket build type) with a sample each to edit on */
kinds: function () { return loadParams().then(function () { return KINDS; }); },
editor: function () { return editor; },
values: function () { return Object.assign({}, values); }
};
})();

View File

@@ -0,0 +1,238 @@
/**
* The icon pose editor's 3D view: a player model's .nif or a car or rocket's assembled modules, seen exactly as the
* UGC server's icon camera sees them (ugc-pose-math.js, the same math as UgcIconPose): the same perspective, framing
* and model turn, with the icon's square outlined. Dragging changes the icon parameters: the camera around the model,
* the model's own turn, the sun, or the icon's shift; the wheel changes the border. The light is an approximation (no
* shadows or highlights); the preview beside it is the UGC server's own drawing.
*/
import * as THREE from 'three';
import { parseModel, mergeMeshes, linearColors } from '/js/scenery-core.js';
import { cameraDirection, computeFrame, modelRotation, viewProjection, wrapDegrees } from '/js/ugc-pose-math.js';
// How much more than the icon's square the view shows around it
const CONTEXT = 1.35;
const DEGREES_PER_PIXEL = 0.4;
export function createPoseEditor(container, options = {}) {
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.LinearToneMapping;
container.textContent = '';
container.style.position = 'relative';
container.style.touchAction = 'none';
container.style.overflow = 'hidden';
container.appendChild(renderer.domElement);
renderer.domElement.style.display = 'block';
// The icon's square
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;pointer-events:none;border:1px dashed rgba(255,200,0,.9);box-shadow:0 0 0 9999px rgba(0,0,0,.4)';
container.appendChild(overlay);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a2e35);
const ambient = new THREE.AmbientLight(0xffffff, 1);
const sun = new THREE.DirectionalLight(0xffffff, 1);
const fill = new THREE.DirectionalLight(0xffffff, 0);
scene.add(ambient, sun, sun.target, fill, fill.target);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000);
camera.matrixAutoUpdate = false;
const root = new THREE.Group();
root.matrixAutoUpdate = false;
scene.add(root);
const sunArrow = new THREE.ArrowHelper(new THREE.Vector3(0, -1, 0), new THREE.Vector3(), 1, 0xffcc33);
scene.add(sunArrow);
let positions = new Float32Array(0);
let parts = [];
let values = {};
let mode = 'camera';
let frame = null;
let disposed = false, queued = false;
let showSun = true;
function resize() {
const width = container.clientWidth || 300, height = container.clientHeight || 300;
renderer.setSize(width, height, false);
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
const side = Math.min(width, height) / CONTEXT;
overlay.style.width = overlay.style.height = side + 'px';
overlay.style.left = (width - side) / 2 + 'px';
overlay.style.top = (height - side) / 2 + 'px';
draw();
}
const observer = new ResizeObserver(resize);
observer.observe(container);
function v(key, fallback) { return Number.isFinite(values[key]) ? values[key] : fallback; }
// The renderer's camera and turn for the current values; the scene drawn once
function update() {
queued = false;
if (disposed) return;
const turn = modelRotation(v('modelYaw', 0), v('modelPitch', 0), v('modelRoll', 0));
frame = computeFrame(positions, turn, { yaw: v('yaw', 0), pitch: v('pitch', 0), fov: v('fov', 40), margin: v('margin', 1), offsetX: v('offsetX', 0), offsetY: v('offsetY', 0) });
root.matrix.fromArray(turn);
root.matrixWorldNeedsUpdate = true;
const width = container.clientWidth || 300, height = container.clientHeight || 300;
camera.matrix.fromArray(frame.view).invert();
camera.matrixWorldNeedsUpdate = true;
camera.updateMatrixWorld(true);
if (frame.ok) {
camera.projectionMatrix.fromArray(viewProjection(frame, width / height, CONTEXT));
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
}
// The light, as close as a simple material gets to the icon's (UgcRender: ambient radiance, irradiances over pi)
const center = new THREE.Vector3(...frame.center);
const toSun = new THREE.Vector3(...cameraDirection(v('sunYaw', 21), v('sunPitch', 50)));
sun.position.copy(center).addScaledVector(toSun, frame.radius * 4);
sun.target.position.copy(center);
sun.intensity = v('sunStrength', 2);
ambient.intensity = v('ambient', 1) * Math.PI;
fill.position.set(...frame.eye);
fill.target.position.copy(center);
fill.intensity = v('fill', 0);
renderer.toneMappingExposure = v('exposure', 1);
renderer.domElement.style.filter = 'contrast(' + v('contrast', 1) + ')';
sunArrow.visible = showSun;
sunArrow.position.copy(center).addScaledVector(toSun, frame.radius * 1.3);
sunArrow.setDirection(toSun.clone().negate());
sunArrow.setLength(frame.radius * 0.6, frame.radius * 0.15, frame.radius * 0.08);
renderer.render(scene, camera);
}
function draw() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
function clear() {
for (const part of parts) {
root.remove(part);
part.geometry.dispose();
part.material.dispose();
}
parts = [];
positions = new Float32Array(0);
}
// Dragging: the mode's parameters (or by modifier: shift pans, ctrl turns the model, alt moves the sun)
let drag = null;
function changed(keys) {
draw();
if (options.onChange) options.onChange(Object.fromEntries(keys.map((k) => [k, values[k]])));
}
function clamp(key, value) {
const limits = options.limits && options.limits[key];
return limits ? Math.min(limits.max, Math.max(limits.min, value)) : value;
}
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
renderer.domElement.addEventListener('pointerdown', (e) => {
renderer.domElement.setPointerCapture(e.pointerId);
const how = e.button === 2 || e.shiftKey ? 'shift' : e.ctrlKey || e.metaKey ? 'model' : e.altKey ? 'sun' : mode;
drag = { x: e.clientX, y: e.clientY, how };
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
drag.x = e.clientX;
drag.y = e.clientY;
if (drag.how === 'camera') {
values.yaw = wrapDegrees(v('yaw', 0) - dx * DEGREES_PER_PIXEL);
values.pitch = clamp('pitch', v('pitch', 0) + dy * DEGREES_PER_PIXEL);
changed(['yaw', 'pitch']);
} else if (drag.how === 'model') {
values.modelYaw = wrapDegrees(v('modelYaw', 0) + dx * DEGREES_PER_PIXEL);
values.modelPitch = clamp('modelPitch', v('modelPitch', 0) + dy * DEGREES_PER_PIXEL);
changed(['modelYaw', 'modelPitch']);
} else if (drag.how === 'sun') {
values.sunYaw = wrapDegrees(v('sunYaw', 0) - dx * DEGREES_PER_PIXEL);
values.sunPitch = clamp('sunPitch', v('sunPitch', 0) - dy * DEGREES_PER_PIXEL);
changed(['sunYaw', 'sunPitch']);
} else {
const side = Math.min(container.clientWidth, container.clientHeight) / CONTEXT;
values.offsetX = clamp('offsetX', v('offsetX', 0) + dx / side);
values.offsetY = clamp('offsetY', v('offsetY', 0) - dy / side);
changed(['offsetX', 'offsetY']);
}
});
const end = () => { drag = null; };
renderer.domElement.addEventListener('pointerup', end);
renderer.domElement.addEventListener('pointercancel', end);
renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.altKey || mode === 'model' && e.shiftKey) {
values.modelRoll = wrapDegrees(v('modelRoll', 0) + Math.sign(e.deltaY) * 5);
changed(['modelRoll']);
return;
}
values.margin = clamp('margin', v('margin', 1) * Math.exp(Math.sign(e.deltaY) * 0.05));
changed(['margin']);
}, { passive: false });
return {
/** Loads a mesh (the dashboard's converted .nif); resolves {triangles, vertices} */
async load(url) {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
let message = response.status === 408 ? 'Being made, try again in a moment' : 'HTTP ' + response.status;
try { message = (await response.json()).error || message; } catch (e) { /* not JSON */ }
throw new Error(message);
}
const model = parseModel(await response.arrayBuffer());
clear();
let triangles = 0, vertices = 0;
const all = [];
for (const mesh of mergeMeshes(model.meshes)) {
if (!mesh.vertices || !mesh.indices.length) continue;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
if (mesh.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3, true));
const hasColors = !!(mesh.colors && mesh.vertexColors !== 0);
if (hasColors) geometry.setAttribute('color', new THREE.BufferAttribute(linearColors(mesh.colors), 4, true));
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
if (!mesh.normals) geometry.computeVertexNormals();
let seeThrough = !!mesh.blend && mesh.alpha < 0.99;
if (mesh.blend && hasColors) for (let i = 3; i < mesh.colors.length && !seeThrough; i += 4) seeThrough = mesh.colors[i] < 250;
const color = new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace);
const material = new THREE.MeshLambertMaterial({ color, vertexColors: hasColors, transparent: seeThrough, side: THREE.DoubleSide });
const object = new THREE.Mesh(geometry, material);
if (seeThrough) object.renderOrder = 1;
root.add(object);
parts.push(object);
all.push(mesh.positions);
triangles += mesh.indices.length / 3;
vertices += mesh.vertices;
}
positions = new Float32Array(vertices * 3);
let at = 0;
for (const list of all) {
positions.set(list, at);
at += list.length;
}
draw();
return { triangles, vertices };
},
/** The icon parameters (any subset; the rest stay) */
setValues(next) {
values = { ...values, ...next };
draw();
},
values() { return { ...values }; },
/** What a plain drag changes: camera, model, sun or shift */
setMode(next) { mode = next; },
setSunArrow(on) { showSun = on; draw(); },
/** The view as a PNG data URL (for comparing with the icon) */
snapshot() { update(); return renderer.domElement.toDataURL('image/png'); },
frame() { return frame; },
dispose() {
disposed = true;
observer.disconnect();
clear();
renderer.dispose();
renderer.domElement.remove();
overlay.remove();
}
};
}

View File

@@ -237,8 +237,7 @@
['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', !canManage);
if (canManage) openFraming(item, model);
UgcIconEditor.open(model ? { model: true, id: id } : { model: false, modules: item.modules || '', label: item.combination });
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (!model) return;
@@ -319,6 +318,7 @@
$('reframeButton').addEventListener('click', function () { if (nifViewer) nifViewer.frame(); });
$('previewModal').addEventListener('hidden.bs.modal', function () {
current = null;
UgcIconEditor.close();
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlViewer = null;
// Closed here rather than with Back: take the item out of the address too
@@ -368,105 +368,30 @@
$('purgeButton').addEventListener('click', function () { purge(false); });
$('purgeAllButton').addEventListener('click', function () { purge(true); });
// ---- icon framing and light, for any item; the controls come from the server's list of parameters ----
// ---- icon presets per type (the editor is ugc-icon-editor.js) ----
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 loadPresets() {
UgcIconEditor.kinds().then(function (kinds) {
$('presetsList').innerHTML = kinds.map(function (k) {
var sample = k.sample ? (k.kind === 'model' ? 'model ' + k.sample : 'modules ' + k.sample + (k.sampleBuilds ? ' (' + k.sampleBuilds + ' builds)' : '')) : 'nothing made yet';
return '<button type="button" class="btn btn-sm btn-outline-primary text-start" data-preset="' + esc(k.kind) + '"' + (k.sample ? '' : ' disabled') + '>' +
'<span class="fw-semibold">' + esc(k.label) + '</span><br><span class="small text-body-secondary">on ' + esc(sample) + '</span></button>';
}).join('');
$('presetsList').onclick = function (e) {
var button = e.target.closest('[data-preset]');
var entry = button && kinds.find(function (k) { return k.kind === button.dataset.preset; });
if (!entry || !entry.sample) return;
if (entry.kind === 'model') {
setKind('model');
preview(String(entry.sample));
} else {
setKind('modular');
preview(entry.sample, { id: entry.sample, modules: entry.sample.replace(/-/g, '+'), combination: entry.sample, state: 'done', sharedBy: entry.sampleBuilds });
}
};
}).catch(function (e) { $('presetsList').textContent = 'Could not load the icon types: ' + e.message; });
}
function framingValues() {
var out = {};
PARAMS.forEach(function (p) { out[p.key] = parseFloat($('icon_' + p.key).value); });
return out;
}
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 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, 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/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) {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = URL.createObjectURL(blob);
img.src = previewUrl;
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 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);
});
$('framingPreviewButton').addEventListener('click', renderPreview);
$('framingReset').addEventListener('click', function () {
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 () {
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/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 () {
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');
});
});
loadPresets();
if (window.Live) Live.on('ugc', load);

View File

@@ -17,6 +17,15 @@
<div class="card-body py-2 small" id="serverStatus">Asking the UGC server…</div>
</div>
<details class="card mb-3" id="presetsCard">
<summary class="card-header py-2 small fw-semibold">Icon presets per type</summary>
<div class="card-body py-2 small">
<p class="text-body-secondary mb-2">Each type of icon (player models, and each car or rocket build type in the client's data) has a preset over the
default settings. Open one to set its camera, the model's turn, the light and the border on an example.</p>
<div class="d-flex flex-wrap gap-2" id="presetsList">Loading…</div>
</div>
</details>
<details class="card mb-3 d-none" id="cacheCard">
<summary class="card-header py-2 small fw-semibold">Stored files: delete and purge</summary>
<div class="card-body py-2 small">
@@ -109,21 +118,42 @@
</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 and light
<span class="ms-auto text-body-secondary fw-normal" id="framingKind"></span></div>
<div class="card-header py-2 small fw-semibold d-flex flex-wrap align-items-center gap-2">Icon editor
<span class="text-body-secondary fw-normal" id="framingKind"></span>
<span class="ms-auto text-body-secondary fw-normal" id="framingState">Preview drawn by the UGC server, not stored.</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>
<div class="col-md-4 text-center"><img id="framingPreview" alt="Preview" class="border rounded ugc-checker" width="128" height="128">
<div class="text-body-secondary" id="framingState">Preview drawn by the UGC server, not stored.</div></div>
</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 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 kind again</button>
<div class="col-lg-8">
<div class="d-flex flex-wrap gap-2 align-items-center mb-1">
<div class="btn-group btn-group-sm" role="group" aria-label="What dragging changes" id="poseModes">
<button type="button" class="btn btn-outline-secondary active" data-mode="camera" title="Drag: the camera around the model">Camera</button>
<button type="button" class="btn btn-outline-secondary" data-mode="model" title="Drag: turn the model (Ctrl+drag in any mode; Alt+wheel rolls it)">Turn model</button>
<button type="button" class="btn btn-outline-secondary" data-mode="sun" title="Drag: the sun's direction (Alt+drag in any mode)">Sun</button>
<button type="button" class="btn btn-outline-secondary" data-mode="shift" title="Drag: move the model in the icon (Shift+drag or right drag in any mode)">Shift</button>
</div>
<div class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" id="sunArrowSwitch" checked><label class="form-check-label" for="sunArrowSwitch">Sun arrow</label></div>
<span class="text-body-secondary">Wheel: border. The dashed square is the icon.</span>
</div>
<div id="poseView" class="rounded border" style="height: 420px"></div>
<div class="text-body-secondary mt-1" id="poseStats"></div>
</div>
<div class="col-lg-4 text-center">
<div class="d-flex justify-content-center gap-2 flex-wrap">
<figure class="mb-0"><img id="framingPreview" alt="Icon preview" class="border rounded ugc-checker" width="256" height="256" style="image-rendering: pixelated">
<figcaption class="text-body-secondary">Preview</figcaption></figure>
</div>
<div class="d-flex flex-column gap-1 mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingPreviewButton">Draw the preview again</button>
<button type="button" class="btn btn-sm btn-outline-primary ugc-manage" id="framingSaveType">Save as the preset for this type</button>
<button type="button" class="btn btn-sm btn-outline-primary ugc-manage" id="framingSaveCombo">Save for this item</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingResetType">Back to the type's preset</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="framingResetDefaults">Back to the default settings</button>
<button type="button" class="btn btn-sm btn-outline-danger ugc-manage" id="framingReset">Remove this item's own values</button>
<button type="button" class="btn btn-sm btn-outline-warning ugc-manage" id="framingRegenerate">Draw all icons of this type again</button>
</div>
</div>
</div>
<div class="row g-2 mt-1" id="framingControls"></div>
</div>
</div>
</div>
@@ -158,5 +188,6 @@
}
</script>
<script src="/js/ldd-materials.js"></script>
<script src="/js/ugc-icon-editor.js"></script>
<script src="/js/ugc.js"></script>
{% endblock %}