mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
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:
233
dDashboardServer/static/js/ugc-icon-editor.js
Normal file
233
dDashboardServer/static/js/ugc-icon-editor.js
Normal 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); }
|
||||
};
|
||||
})();
|
||||
238
dDashboardServer/static/js/ugc-pose.js
Normal file
238
dDashboardServer/static/js/ugc-pose.js
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user