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 '
'; + }).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 ? '' : '' + 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.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.
+