mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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>
239 lines
10 KiB
JavaScript
239 lines
10 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
};
|
|
}
|