mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-03 03:13:50 +00:00
feat(dashboard): draw generated models in the property 3D view
Player-built models the UGC server made are drawn from their NIF, falling back to the LXFML; a Generated models switch turns it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||
import { parseModel, mergeMeshes, linearColors } from '/js/scenery-core.js';
|
||||
|
||||
const GEOMETRY_MAGIC = 0x42473031; // "10GB"
|
||||
const MAX_PARALLEL_FETCHES = 6;
|
||||
@@ -143,6 +144,33 @@ export function parseLxfml(text) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a model the UGC server made (a .nif, converted by the dashboard like the scenery's models: see
|
||||
* scenery-core.js), as [{geometry, material}] in the model's own coordinates. Each part owns its geometry and material.
|
||||
*/
|
||||
async function loadGeneratedModel(url) {
|
||||
const response = await limitedFetch(url);
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const model = parseModel(await response.arrayBuffer());
|
||||
const parts = [];
|
||||
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();
|
||||
geometry.computeBoundingBox();
|
||||
const color = new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace);
|
||||
const material = new THREE.MeshStandardMaterial({ color, vertexColors: hasColors, transparent: !!mesh.blend, roughness: 0.5, metalness: 0 });
|
||||
parts.push({ geometry, material });
|
||||
}
|
||||
if (!parts.length) throw new Error('empty model');
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ---- Viewer ----
|
||||
|
||||
const materialCache = new Map();
|
||||
@@ -523,6 +551,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
|
||||
function clear() {
|
||||
for (const mesh of meshes) {
|
||||
root.remove(mesh);
|
||||
if (mesh.userData.owned) { mesh.geometry.dispose(); mesh.material.dispose(); }
|
||||
mesh.dispose();
|
||||
}
|
||||
meshes = [];
|
||||
@@ -533,15 +562,34 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
|
||||
if (grid) { scene.remove(grid); grid.geometry.dispose(); grid = null; }
|
||||
}
|
||||
|
||||
/** Load property models: [{id, name, lot, position: [x,y,z], rotation: [x,y,z,w]}], each from `url` when given (else its property model LXFML). */
|
||||
/**
|
||||
* Load property models: [{id, name, lot, position: [x,y,z], rotation: [x,y,z,w]}], each from `url` when given (else
|
||||
* its property model LXFML). A model with `meshUrl` is drawn from the model the UGC server made of it instead, or
|
||||
* from its LXFML when that can't be had (model.generated says which it is drawn from).
|
||||
*/
|
||||
async function load(list, lod = 2) {
|
||||
clear();
|
||||
const batches = new Map(); // "design/part/material" -> {geometry, material, matrices: [], modelIndex: []}
|
||||
const boxes = list.map(() => new THREE.Box3());
|
||||
const generated = new Set(); // indexes of the models drawn from the UGC server's model
|
||||
let loaded = 0;
|
||||
|
||||
await Promise.all(list.map(async (model, modelIndex) => {
|
||||
try {
|
||||
if (model.meshUrl) {
|
||||
try {
|
||||
const placement = new THREE.Matrix4().compose(new THREE.Vector3(...model.position), new THREE.Quaternion(...model.rotation), new THREE.Vector3(1, 1, 1));
|
||||
const parts = await loadGeneratedModel(model.meshUrl);
|
||||
parts.forEach((part, index) => {
|
||||
batches.set(`generated/${modelIndex}/${index}`, { ...part, owned: true, matrices: [placement], modelIndex: [modelIndex] });
|
||||
boxes[modelIndex].union(part.geometry.boundingBox.clone().applyMatrix4(placement));
|
||||
});
|
||||
generated.add(modelIndex);
|
||||
return;
|
||||
} catch (e) {
|
||||
// Not made yet, or the UGC server is away: its LXFML instead
|
||||
}
|
||||
}
|
||||
const response = await fetch(model.url || `/api/property_models/${model.id}/lxfml`, { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const parts = parseLxfml(await response.text());
|
||||
@@ -576,12 +624,13 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.modelIndex = batch.modelIndex;
|
||||
mesh.userData.owned = !!batch.owned; // its geometry and material are its own, not shared bricks
|
||||
mesh.computeBoundingSphere();
|
||||
root.add(mesh);
|
||||
meshes.push(mesh);
|
||||
}
|
||||
|
||||
models = list.map((model, i) => ({ ...model, box: boxes[i], instances: [], offset: new THREE.Vector3(), visible: true }));
|
||||
models = list.map((model, i) => ({ ...model, generated: generated.has(i), box: boxes[i], instances: [], offset: new THREE.Vector3(), visible: true }));
|
||||
for (const mesh of meshes) {
|
||||
mesh.userData.modelIndex.forEach((modelIndex, index) => {
|
||||
const base = new THREE.Matrix4();
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
<div class="form-check form-switch mb-0 small"><input class="form-check-input" type="checkbox" id="shadowToggle" data-pref="property3d.shadows" checked><label class="form-check-label" for="shadowToggle">Shadows</label></div>
|
||||
<div class="form-check form-switch mb-0 small" title="The zone's sky, as the game draws it (with Scenery on)"><input class="form-check-input" type="checkbox" id="skyToggle" data-pref="property3d.sky" checked><label class="form-check-label" for="skyToggle">Sky</label></div>
|
||||
<div class="form-check form-switch mb-0 small" title="The models placed on the property"><input class="form-check-input" type="checkbox" id="modelsToggle" data-pref="property3d.models" checked><label class="form-check-label" for="modelsToggle">Placed models</label></div>
|
||||
{% if not exists("showcase") %}<div class="form-check form-switch mb-0 small d-none" id="generatedWrap" title="Draw player-built models as the UGC server made them for the game (on: generated models; off: built from their LXFML). Models it hasn't made yet are built from their LXFML either way"><input class="form-check-input" type="checkbox" id="generatedToggle" data-pref="property3d.generated" checked><label class="form-check-label" for="generatedToggle">Generated models</label></div>{% endif %}
|
||||
<div class="form-check form-switch mb-0 small" title="Also draw, see-through, the models of objects the game doesn't draw: trigger and blocking volumes (with Scenery on)"><input class="form-check-input" type="checkbox" id="hiddenToggle" data-pref="property3d.hidden"><label class="form-check-label" for="hiddenToggle">Hidden objects</label></div>
|
||||
</div>
|
||||
<div class="text-body-secondary" id="boundaryInfo"></div>
|
||||
@@ -118,6 +119,7 @@
|
||||
}
|
||||
</script>
|
||||
<script src="/js/ldd-materials.js"></script>
|
||||
<script src="/js/ugc-links.js"></script>
|
||||
<script type="module">
|
||||
import { createViewer } from '/js/lddviewer.js';
|
||||
import { createBehaviorPlayer, describeAction, isTrigger, isSupported } from '/js/behaviorplayer.js';
|
||||
@@ -130,6 +132,7 @@ const propertyApi = (showcase ? '/api/showcase/' : '/api/properties/') + propert
|
||||
const assetApi = showcase ? '/api/showcase' : '/api';
|
||||
const setText = (id, value) => { document.getElementById(id).textContent = value; };
|
||||
let property = null;
|
||||
let ugc = {}; // model id -> what the UGC server made of it (/api/ugc_links/property/:id)
|
||||
let loadedLod = null;
|
||||
let selectedIndex = -1;
|
||||
let terrainData; // undefined: not fetched yet; null: none for this zone
|
||||
@@ -213,10 +216,19 @@ function showSelection(model, index) {
|
||||
'<dt class="col-4">LOT</dt><dd class="col-8">' + esc(model.lot) + '</dd>' +
|
||||
'<dt class="col-4">Model ID</dt><dd class="col-8 text-break">' + esc(model.id) + '</dd>' +
|
||||
'<dt class="col-4">Position</dt><dd class="col-8">' + model.position.map((v) => esc(v.toFixed(2))).join(', ') + '</dd>' +
|
||||
'<dt class="col-4">Rotation</dt><dd class="col-8">' + esc(yaw) + '°</dd></dl>' +
|
||||
'<dt class="col-4">Rotation</dt><dd class="col-8">' + esc(yaw) + '°</dd>' + ugcInfo(model) + '</dl>' +
|
||||
(showcase ? '' : '<a class="btn btn-sm btn-outline-secondary" href="/api/property_models/' + esc(model.id) + '/lxfml">Download LXFML</a>');
|
||||
}
|
||||
|
||||
// A player-built model's UGC server state, what it is drawn from, and a link to it in the UGC viewer
|
||||
function ugcInfo(model) {
|
||||
const item = ugc[model.id];
|
||||
if (!item) return '';
|
||||
return '<dt class="col-4">UGC</dt><dd class="col-8">' + UgcLinks.state(item) + ' <span class="small text-body-secondary">' +
|
||||
(model.generated ? 'drawn from the generated model' : 'drawn from its LXFML') + '</span>' +
|
||||
(item.link ? '<br><a class="small" href="' + esc(item.link) + '">Open in UGC viewer</a>' : '') + '</dd>';
|
||||
}
|
||||
|
||||
function renderBehaviors() {
|
||||
const panel = document.getElementById('behaviorPanel');
|
||||
const model = viewer.models()[selectedIndex];
|
||||
@@ -384,6 +396,10 @@ async function loadScene() {
|
||||
if (!property || loadedLod === lod) return;
|
||||
loadedLod = lod;
|
||||
const keep = selectedIndex >= 0 ? viewer.models()[selectedIndex].id : (location.hash.match(/model=(\d+)/) || [])[1];
|
||||
// Player-built models the UGC server made are drawn as it made them, unless switched off
|
||||
const generatedToggle = document.getElementById('generatedToggle');
|
||||
const generated = !!(generatedToggle && generatedToggle.checked);
|
||||
property.models.forEach((m) => { m.meshUrl = generated && ugc[m.id] && ugc[m.id].mesh ? ugc[m.id].mesh : null; });
|
||||
await viewer.load(property.models, lod);
|
||||
player.reset();
|
||||
renderModelList();
|
||||
@@ -396,8 +412,11 @@ async function loadScene() {
|
||||
}
|
||||
|
||||
function load() {
|
||||
api.get(propertyApi).then((p) => {
|
||||
Promise.all([api.get(propertyApi), showcase ? {} : UgcLinks.forProperty(propertyId)]).then(([p, made]) => {
|
||||
if (p.success === false) { setText('propName', p.error); return; }
|
||||
ugc = made;
|
||||
const generatedWrap = document.getElementById('generatedWrap');
|
||||
if (generatedWrap) generatedWrap.classList.toggle('d-none', !Object.values(made).some((item) => item.mesh));
|
||||
// Showcase models come through the showcase, which checks they're on this property
|
||||
if (showcase) p.models.forEach((m) => { m.url = propertyApi + '/models/' + m.id + '/lxfml'; });
|
||||
property = p;
|
||||
@@ -422,6 +441,7 @@ document.getElementById('sceneryToggle').addEventListener('change', showScenery)
|
||||
document.getElementById('skyToggle').addEventListener('change', showScenery);
|
||||
document.getElementById('hiddenToggle').addEventListener('change', showScenery);
|
||||
document.getElementById('modelsToggle').addEventListener('change', (e) => viewer.setModelsVisible(e.target.checked));
|
||||
if (document.getElementById('generatedToggle')) document.getElementById('generatedToggle').addEventListener('change', () => { loadedLod = null; loadScene(); });
|
||||
document.getElementById('shadowToggle').addEventListener('change', (e) => viewer.setShadows(e.target.checked));
|
||||
document.getElementById('resetViewBtn').addEventListener('click', () => viewer.resetView());
|
||||
document.getElementById('fullscreenBtn').addEventListener('click', () => {
|
||||
|
||||
Reference in New Issue
Block a user