From d0e6c96ead825bfd6eaed2e7357dc90507dce916 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 15:32:19 -0500 Subject: [PATCH] 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 --- dDashboardServer/static/js/ugc-icon-editor.js | 233 +++++++++++++++++ dDashboardServer/static/js/ugc-pose.js | 238 ++++++++++++++++++ dDashboardServer/static/js/ugc.js | 123 ++------- dDashboardServer/templates/ugc.jinja2 | 55 +++- 4 files changed, 538 insertions(+), 111 deletions(-) create mode 100644 dDashboardServer/static/js/ugc-icon-editor.js create mode 100644 dDashboardServer/static/js/ugc-pose.js diff --git a/dDashboardServer/static/js/ugc-icon-editor.js b/dDashboardServer/static/js/ugc-icon-editor.js new file mode 100644 index 000000000..afb6612ce --- /dev/null +++ b/dDashboardServer/static/js/ugc-icon-editor.js @@ -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 '
' + esc(g[1]) + '' + + list.map(function (p) { + return ''; + }).join('') + '
'; + }).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); } + }; +})(); diff --git a/dDashboardServer/static/js/ugc-pose.js b/dDashboardServer/static/js/ugc-pose.js new file mode 100644 index 000000000..29a38713d --- /dev/null +++ b/dDashboardServer/static/js/ugc-pose.js @@ -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(); + } + }; +} diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js index 8817b5d92..e73657edc 100644 --- a/dDashboardServer/static/js/ugc.js +++ b/dDashboardServer/static/js/ugc.js @@ -237,8 +237,7 @@ ['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); }); $('statsBox').innerHTML = model ? '' : '
Modules: ' + esc(item.modules || '') + ' (combination ' + esc(item.combination || '') + '). The game client puts cars and rockets together itself; only the icon is made, once per combination of modules.
'; - $('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 '
'; - }).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 ''; + }).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); diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index 15200147d..7bc29d6b3 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -17,6 +17,15 @@
Asking the UGC server…
+
+ Icon presets per type +
+

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.

+
Loading…
+
+
+
Stored files: delete and purge
@@ -109,21 +118,42 @@
-
Icon framing and light -
+
Icon editor + + Preview drawn by the UGC server, not stored.
-
-
Preview -
Preview drawn by the UGC server, not stored.
-
-
- - - - - +
+
+
+ + + + +
+
+ Wheel: border. The dashed square is the icon. +
+
+
+
+
+
+
Icon preview +
Preview
+
+
+ + + + + + + +
+
+
@@ -158,5 +188,6 @@ } + {% endblock %}