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:
Aaron Kimbrell
2026-09-27 10:23:35 -05:00
parent 6b6897dd64
commit 4017785f6a
2 changed files with 73 additions and 4 deletions

View File

@@ -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();

View File

@@ -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) + '&deg;</dd></dl>' +
'<dt class="col-4">Rotation</dt><dd class="col-8">' + esc(yaw) + '&deg;</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', () => {