Files
DarkflameServer/dDashboardServer/templates/property-3d.jinja2
Aaron Kimbrell 59b9ceb52c feat(dashboard): import, reprocess and remove models from the property 3D view
The 3D view gets the property page's model actions: Reprocess all models
(ugc_manage), Import LXFML at the file's positions and Remove all models
(properties_import). Not on the showcase view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:18 -05:00

523 lines
33 KiB
Django/Jinja

{% extends "base.jinja2" %}
{% block title %}3D View - DarkflameServer{% endblock %}
{% block css %}
<style>
/* The view fills the window below the top bar and the page itself never scrolls: panels scroll inside */
body:has(#property) .main-column { height: 100vh; min-height: 0 !important; overflow: hidden; }
body:has(#property) main { display: flex; flex-direction: column; min-height: 0; overflow: hidden; padding: .5rem !important; }
#property { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; }
.viewer-layout { flex: 1 1 auto; display: flex; gap: .5rem; min-height: 0; }
.viewer-column { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
#viewerWrap { flex: 1 1 auto; min-height: 0; }
#viewerWrap:fullscreen #viewer { height: 100vh; }
.viewer-panel-wrap { width: 22rem; flex: 0 0 auto; }
@media (min-width: 1400px) { .viewer-panel-wrap { width: 25rem; } }
@media (max-width: 991.98px) { .viewer-panel-wrap.offcanvas-lg { height: 60vh; width: auto; } }
.viewer-panel .list-group-item { cursor: pointer; }
.viewer-panel .tab-pane { overflow-y: auto; overflow-x: hidden; }
#propDescription { max-height: 3em; overflow-y: auto; }
</style>
{% endblock %}
{% block content %}
{# Also the showcase's read-only view of approved public properties (Showcase.cpp passes showcase) #}
{% if not exists("showcase") %}<nav data-crumbs data-crumb-label="3D view" aria-label="Breadcrumb"><ol class="breadcrumb mb-2">{% if can.properties_view %}<li class="breadcrumb-item"><a href="/properties">Properties</a></li>{% endif %}<li class="breadcrumb-item"><a href="/properties/{{ property_id }}">Property {{ property_id }}</a></li><li class="breadcrumb-item active" aria-current="page">3D view</li></ol></nav>{% endif %}
<div id="property" data-property-id="{{ property_id }}" data-behavior-rules="{{ behavior_rules }}" data-showcase="{% if exists("showcase") %}1{% endif %}">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
{% if exists("showcase") %}<a href="/showcase" class="btn btn-sm btn-outline-secondary">&larr; Showcase</a>{% else %}<a href="/properties/{{ property_id }}" class="btn btn-sm btn-outline-secondary" data-crumb-back>&larr; Property</a>{% endif %}
<div class="me-auto">
<h5 class="mb-0 d-inline" id="propName">Loading&hellip;</h5>
<span class="small text-body-secondary ms-2" id="propMeta"></span>
</div>
<span class="small text-body-secondary" id="viewerProgress"></span>
<button class="btn btn-sm btn-outline-secondary" id="resetViewBtn">Reset view</button>
<button class="btn btn-sm btn-outline-secondary" id="resetPropertyBtn" title="Start the property over as it's saved: every model back in its place and not smashed, behaviors stopped, the log and selection cleared, the view framed on the plot">Reset property</button>
{% if not exists("showcase") %}
{% if can.ugc_manage %}<button class="btn btn-sm btn-outline-secondary" id="reprocessModelsBtn" title="Have the UGC server make every model on this property again (mesh and icon), with the current settings">Reprocess all models</button>{% endif %}
{% if can.properties_import %}
<button class="btn btn-sm btn-outline-secondary" id="importModelsBtn" title="Add the models in an .lxfml file at the positions stored in it, owned by the property's owner. Not while the property is loaded in a world">Import LXFML</button>
<input type="file" id="importModelsFile" accept=".lxfml" class="d-none">
<button class="btn btn-sm btn-outline-danger" id="removeModelsBtn" title="Delete every model placed on this property (they don't go back to the owner). Not while the property is loaded in a world">Remove all models</button>
{% endif %}
{% endif %}
<button class="btn btn-sm btn-outline-secondary" id="fullscreenBtn">Full screen</button>
<button class="btn btn-sm btn-outline-primary d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#viewerPanel" aria-controls="viewerPanel">Panel</button>
</div>
<p class="small text-body-secondary mb-2 d-none" id="propDescription" style="white-space: pre-line"></p>
<div class="viewer-layout">
<div class="viewer-column">
<div id="viewerWrap" class="bg-body">
<div id="viewer" class="rounded overflow-hidden h-100" title="Drag to orbit, right-drag to pan, scroll to zoom. Click a model to select it, double-click to focus."></div>
</div>
<form class="d-flex flex-wrap align-items-center gap-1 mt-2" id="chatBar" aria-label="Say something in chat">
<span class="small text-body-secondary me-1" title="In game every model hears chat, so a phrase can start behaviors on many models at once">Chat (every model hears it):</span>
<span id="chatPhrases" class="d-flex flex-wrap gap-1"></span>
<input class="form-control form-control-sm w-auto flex-grow-1" id="chatInput" placeholder="Type a message" maxlength="100" style="min-width: 10rem" aria-label="Chat message">
<button class="btn btn-sm btn-outline-primary">Say</button>
</form>
</div>
<div class="offcanvas-lg offcanvas-bottom viewer-panel-wrap" tabindex="-1" id="viewerPanel" aria-label="Models, selection, log and layers">
<div class="card viewer-panel h-100 w-100 d-flex flex-column">
<div class="card-header p-1">
<ul class="nav nav-pills nav-fill small" role="tablist">
<li class="nav-item" role="presentation"><button class="nav-link active py-1" data-bs-toggle="pill" data-bs-target="#tabModels" type="button" role="tab">Models <span class="badge text-bg-secondary" id="modelCount">0</span></button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabSelected" type="button" role="tab" id="selectedTabBtn">Selected</button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabLog" type="button" role="tab">Log</button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabLayers" type="button" role="tab">Layers</button></li>
</ul>
</div>
<div class="tab-content flex-grow-1 overflow-hidden">
<div class="tab-pane show active h-100 p-2" id="tabModels" role="tabpanel">
<input type="search" class="form-control form-control-sm mb-2" id="modelSearch" placeholder="Filter models" aria-label="Filter models">
<div class="form-check small mb-2"><input class="form-check-input" type="checkbox" id="onlyBehaviors" data-pref="property3d.onlyBehaviors"><label class="form-check-label" for="onlyBehaviors">Only models with behaviors</label></div>
<div class="list-group list-group-flush small" id="modelList"></div>
</div>
<div class="tab-pane h-100 p-2 small" id="tabSelected" role="tabpanel">
<div id="selectionInfo"><span class="text-body-secondary">Click a model to select it.</span></div>
<div id="behaviorPanel" class="mt-3"></div>
</div>
<div class="tab-pane h-100 p-2 small" id="tabLog" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-body-secondary">What behaviors did, newest first</span>
<button class="btn btn-sm btn-outline-secondary" id="behaviorReset" title="Put every model back and stop its behaviors">Reset models</button>
</div>
<ol class="list-unstyled mb-0" id="behaviorLog"><li class="text-body-secondary">Select a model with behaviors and trigger it.</li></ol>
</div>
<div class="tab-pane h-100 p-2 small" id="tabLayers" role="tabpanel">
<div class="fw-semibold mb-1">View</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<label class="visually-hidden" for="lodSelect">Level of detail</label>
<select id="lodSelect" class="form-select form-select-sm w-auto" data-pref="property3d.detail" title="Detail: how sharp models are and how far scenery is drawn">
<option value="0">High detail</option>
<option value="1">Medium detail</option>
<option value="2" selected>Low detail</option>
</select>
<div class="form-check form-switch mb-0 small"><input class="form-check-input" type="checkbox" id="terrainToggle" data-pref="property3d.terrain" checked><label class="form-check-label" for="terrainToggle">Terrain</label></div>
<div class="form-check form-switch mb-0 small" id="sceneryWrap" title="Every object of the zone around the property, drawn from the game client's files"><input class="form-check-input" type="checkbox" id="sceneryToggle" data-pref="property3d.scenery" checked><label class="form-check-label" for="sceneryToggle">Scenery</label></div>
<label class="visually-hidden" for="sceneMode">Which scenes</label>
<select id="sceneMode" class="form-select form-select-sm w-auto" data-pref="property3d.sceneMode" title="Which of the zone's scenes the scenery shows. As the game loads them: the scene under the camera, the scenes connected to it and the global scene">
<option value="all" selected>Every scene</option>
<option value="game">Scenes as the game loads them</option>
</select>
<div class="form-check form-switch mb-0 small" id="boundaryWrap"><input class="form-check-input" type="checkbox" id="boundaryToggle" data-pref="property3d.boundary" checked><label class="form-check-label" for="boundaryToggle">Build area</label></div>
<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>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js": "sha384-IDC7sAMAIMB/TZ6dgKKPPAKZ2bXXXP8+FBMBC8cU319eBhKITx+PaalhfDkDNH28",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js": "sha384-aJoe4qqS/DgF2jh9njAuvA6QIveJYoCuYOfYjdFY8P3eawzmc9bEQ40jq2TvOyuP",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js": "sha384-9ItPPH2vgKbkvgrqTKq7ptGYe+WuAnqlZtlqleJW4/CJjnM5R7C+efD6iS7ADPJ9"
}
}
</script>
<script src="/api/bricks/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';
import { createScenery } from '/js/scenery.js';
const propertyId = document.getElementById('property').getAttribute('data-property-id');
// The showcase serves only approved public properties, from its own endpoints that don't need owning the property
const showcase = document.getElementById('property').dataset.showcase === '1';
const propertyApi = (showcase ? '/api/showcase/' : '/api/properties/') + propertyId;
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
let player = null; // the viewer ticks once while it is being created, before the player exists
let scenery = null;
const viewer = createViewer(document.getElementById('viewer'), {
onProgress: (loaded, total) => setText('viewerProgress', loaded < total ? `Loading ${loaded}/${total}…` : ''),
onSelect: showSelection,
onTick: (dt) => { if (player) player.tick(dt); if (scenery) scenery.update(dt); },
brickUrl: (lod, design) => assetApi + '/bricks/' + lod + '/' + design
});
// The behavior blocks the server plays (BehaviorXml::Rules)
const behaviorRules = JSON.parse(document.getElementById('property').dataset.behaviorRules);
player = createBehaviorPlayer(viewer, behaviorRules, { onLog: logLine, onChange: renderBehaviors });
// The zone around the property as the game draws it: its objects' models and the sky, nearest first
{
const { scene, camera, renderer, controls } = viewer.three();
const assetBase = showcase ? '/api/showcase/scenery/' : '/api/scenery/';
let sceneryText = '';
scenery = createScenery({
scene, camera, renderer,
urls: {
manifest: propertyApi + '/scenery',
mesh: (zone, asset, lod) => assetBase + zone + '/mesh/' + asset + '?lod=' + lod,
texture: (zone, asset, slot, lod) => assetBase + zone + '/texture/' + asset + '/' + slot + '?lod=' + lod
},
focus: (out) => out.copy(controls.target),
onProgress: (loaded, wanted) => {
sceneryText = loaded < wanted ? `Scenery ${loaded}/${wanted}…` : '';
const progress = document.getElementById('viewerProgress');
if (!progress.textContent || progress.textContent.startsWith('Scenery')) progress.textContent = sceneryText;
}
});
}
// Shadows and placed models start on; the toggles may have been left off (saved per account)
if (!document.getElementById('shadowToggle').checked) viewer.setShadows(false);
if (!document.getElementById('modelsToggle').checked) viewer.setModelsVisible(false);
// Player-built models all share one name, so tell them apart by their behavior's name and the end of their ID
function modelLabel(m) {
const behavior = m.behaviors && m.behaviors.find((b) => b.name && b.name !== 'New Behavior');
return (behavior ? behavior.name : m.name) + (m.lot === 14 ? ' #' + String(m.id).slice(-4) : '');
}
// ---- models list ----
function renderModelList() {
const query = document.getElementById('modelSearch').value.trim().toLowerCase();
const only = document.getElementById('onlyBehaviors').checked;
const models = viewer.models();
const items = models.map((m, i) => ({ m, i })).filter(({ m }) =>
(!only || (m.behaviors && m.behaviors.length)) && (!query || (modelLabel(m) + ' ' + m.name + ' ' + m.lot + ' ' + m.id).toLowerCase().includes(query)));
document.getElementById('modelList').innerHTML = items.map(({ m, i }) =>
'<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center px-2' + (i === selectedIndex ? ' active' : '') + '" data-index="' + i + '">' +
'<span>' + esc(modelLabel(m)) + ' <span class="text-body-secondary">LOT ' + esc(m.lot) + '</span></span>' +
(m.behaviors && m.behaviors.length ? '<span class="badge text-bg-primary" title="Behaviors">' + m.behaviors.length + '</span>' : '') + '</a>').join('') ||
'<div class="text-body-secondary p-2">No models match.</div>';
setText('modelCount', models.length);
}
document.getElementById('modelList').addEventListener('click', (e) => {
const item = e.target.closest('[data-index]');
if (item) viewer.select(viewer.models()[Number(item.dataset.index)].id);
});
document.getElementById('modelSearch').addEventListener('input', renderModelList);
document.getElementById('onlyBehaviors').addEventListener('change', renderModelList);
// ---- selection and behaviors ----
function showSelection(model, index) {
selectedIndex = model ? index : -1;
renderModelList();
renderBehaviors();
const box = document.getElementById('selectionInfo');
if (!model) { box.innerHTML = '<span class="text-body-secondary">Click a model to select it.</span>'; return; }
history.replaceState(null, '', '#model=' + model.id);
bootstrap.Tab.getOrCreateInstance(document.getElementById('selectedTabBtn')).show();
const [x, y, z, w] = model.rotation;
const yaw = Math.round(Math.atan2(2 * (w * y + x * z), 1 - 2 * (y * y + x * x)) * 180 / Math.PI);
box.innerHTML = '<h6 class="mb-1">' + esc(modelLabel(model)) + '</h6><dl class="row mb-2">' +
'<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>' + ugcInfo(model) + '</dl>' +
(showcase ? '' : brickBuilt(model) ? '<h6 class="mt-2 mb-1">UGC</h6><div id="ugcDetails" class="small text-body-secondary">Loading…</div>'
: '<a class="btn btn-sm btn-outline-secondary" href="/api/property_models/' + esc(model.id) + '/lxfml">Download LXFML</a>');
if (!showcase && brickBuilt(model)) {
const item = ugc[model.id];
const placed = property && property.models.find((m) => String(m.id) === String(model.id));
UgcLinks.details(item ? item.ugcId : placed && placed.ugc_id, 'property=' + propertyId, model.id).then((html) => {
const box = document.getElementById('ugcDetails');
if (box && selectedIndex === index) { box.className = ''; box.innerHTML = html; }
});
}
}
// Brick-built models (their blueprint is on the UGC server); cars and rockets are modular and drawn by the client
function brickBuilt(model) {
const item = ugc[model.id];
return item ? item.kind === 'model' : model.lot === 14;
}
// 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];
if (!model) { panel.innerHTML = ''; panel.dataset.model = ''; return; }
if (!model.behaviors || !model.behaviors.length) { panel.innerHTML = '<span class="text-body-secondary">No behaviors on this model.</span>'; panel.dataset.model = ''; return; }
const status = player.status(selectedIndex);
const chats = new Set(), triggers = new Set();
let html = '';
model.behaviors.forEach((behavior, b) => {
const current = status[b] ? status[b].state : 0;
html += '<div class="mb-3"><h6 class="mb-1">' + esc(behavior.name || 'Behavior') + '</h6>';
behavior.states.forEach((state) => {
if (!state.strips.length) return;
const active = state.id === current;
html += '<div class="mb-2"><div class="' + (active ? 'fw-semibold' : 'text-body-secondary') + '">' + esc(state.name) + ' state' + (active ? ' (current)' : '') + '</div>';
state.strips.forEach((strip, s) => {
const running = active && status[b] && status[b].strips[s];
if (strip.actions[0]) triggers.add(strip.actions[0].type);
html += '<div class="border rounded p-1 mb-1 d-flex flex-wrap gap-1">' + strip.actions.map((action, a) => {
if (action.type === 'OnChat') chats.add(action.value || '');
const now = running && !running.waiting && running.index === a;
const cls = now ? 'text-bg-warning' : isTrigger(action) ? 'text-bg-primary' : isSupported(action) ? 'text-bg-secondary' : 'text-bg-danger';
return '<span class="badge ' + cls + '"' + (isSupported(action) ? '' : ' title="Not supported by the server"') + '>' + esc(describeAction(action)) + '</span>';
}).join('') + '</div>';
});
html += '</div>';
});
html += '</div>';
});
let buttons = '';
if (triggers.has('OnInteract')) buttons += '<button class="btn btn-sm btn-primary" data-trigger="OnInteract">Interact</button>';
if (triggers.has('OnAttack')) buttons += '<button class="btn btn-sm btn-danger" data-trigger="OnAttack">Attack</button>';
chats.forEach((message) => { buttons += '<button class="btn btn-sm btn-outline-primary" data-chat="' + esc(message) + '" title="Every model hears it, like in game">Say "' + esc(message) + '"</button>'; });
// Behaviors step many times a second: rebuilding the buttons each time would swallow clicks (the button under the
// pointer is replaced between press and release), so they're only built when another model is selected
let strips = panel.querySelector('[data-strips]');
if (panel.dataset.model !== String(selectedIndex) || !strips) {
panel.dataset.model = String(selectedIndex);
panel.innerHTML = '<div class="d-flex flex-wrap gap-1 mb-2">' + (buttons || '<span class="text-body-secondary">No triggers the server supports.</span>') + '</div><div data-strips></div>';
strips = panel.querySelector('[data-strips]');
}
strips.innerHTML = html;
}
// ---- chat: every model hears it ----
function say(message) {
const result = player.chatAll(message);
logLine(-1, 'Said "' + message + '": ' + (result.models ? result.models + ' model' + (result.models === 1 ? '' : 's') + ' reacted (' + result.strips + ' strip' + (result.strips === 1 ? '' : 's') + ')' : 'no model was waiting for that'));
}
// A button for every phrase some model listens for, with how many models listen
function renderChatPhrases() {
const listeners = new Map();
viewer.models().forEach((model) => {
const heard = new Set();
// Any "on chat" step counts, not just a strip's first (a strip can wait for chat part way through)
(model.behaviors || []).forEach((behavior) => behavior.states.forEach((state) => state.strips.forEach((strip) => {
strip.actions.forEach((action) => { if (action.type === 'OnChat') heard.add(action.value || ''); });
})));
heard.forEach((phrase) => listeners.set(phrase, (listeners.get(phrase) || 0) + 1));
});
document.getElementById('chatPhrases').innerHTML = [...listeners.entries()].sort((a, b) => b[1] - a[1]).map(([phrase, count]) =>
'<button type="button" class="btn btn-sm btn-outline-primary" data-chat="' + esc(phrase) + '" title="' + count + ' model' + (count === 1 ? '' : 's') + ' listen for this">"' +
esc(phrase) + '" <span class="badge text-bg-secondary">' + count + '</span></button>').join('');
}
document.addEventListener('click', (e) => {
const chat = e.target.closest('[data-chat]');
if (chat) say(chat.dataset.chat);
});
document.getElementById('chatBar').addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('chatInput');
if (input.value.trim()) say(input.value.trim());
input.value = '';
});
document.getElementById('behaviorPanel').addEventListener('click', (e) => {
const button = e.target.closest('[data-trigger]');
if (!button || selectedIndex < 0) return;
if (!player.trigger(selectedIndex, button.dataset.trigger, button.dataset.message)) logLine(selectedIndex, 'Nothing is waiting for that right now');
});
function logLine(modelIndex, text) {
const list = document.getElementById('behaviorLog');
if (list.firstElementChild && list.firstElementChild.classList.contains('text-body-secondary')) list.textContent = '';
const model = viewer.models()[modelIndex];
const item = document.createElement('li');
item.className = 'mb-1';
item.innerHTML = '<span class="text-body-secondary">' + esc(new Date().toLocaleTimeString()) + '</span> <strong>' + esc(model ? modelLabel(model) : 'All') + '</strong>: ' + esc(text);
list.prepend(item);
while (list.children.length > 100) list.lastElementChild.remove();
}
document.getElementById('behaviorReset').addEventListener('click', () => { player.reset(); logLine(-1, 'Everything reset'); });
// ---- loading ----
let chunkTerrain; // undefined: not fetched yet; null: not available
async function showTerrain() {
const toggle = document.getElementById('terrainToggle');
if (!toggle.checked) { viewer.setTerrain(null); return; }
// The whole zone as the game draws it, textured; the height-shaded patch is the fallback
if (chunkTerrain === undefined) {
const t = await api.get(propertyApi + '/terrain_chunks').catch(() => null);
chunkTerrain = t && t.chunks ? t : null;
}
if (chunkTerrain) {
viewer.setTerrainChunks(chunkTerrain, (id) => assetApi + '/terrain_textures/' + id, scenery.gameLights());
return;
}
if (terrainData === undefined) {
const t = await api.get(propertyApi + '/terrain').catch(() => null);
terrainData = t && t.heights ? t : null;
}
if (!terrainData) {
toggle.checked = false;
toggle.disabled = true;
toggle.parentElement.title = 'No terrain for this zone (the server needs client_location)';
return;
}
viewer.setTerrain(terrainData);
}
// ---- scenery ----
let sceneryShown = null; // the detail level it was loaded at
async function showScenery() {
const toggle = document.getElementById('sceneryToggle');
scenery.setEnabled(toggle.checked);
scenery.setSky(document.getElementById('skyToggle').checked);
scenery.setShowHidden(document.getElementById('hiddenToggle').checked);
if (!toggle.checked) return;
const lod = parseInt(document.getElementById('lodSelect').value, 10);
if (sceneryShown === lod) return;
sceneryShown = lod;
if (!await scenery.load(undefined, lod)) {
toggle.checked = false;
toggle.disabled = true;
document.getElementById('sceneryWrap').title = 'No scenery for this zone (the server needs client_location)';
return;
}
scenery.setSceneMode(document.getElementById('sceneMode').value);
}
document.getElementById('sceneMode').addEventListener('change', (e) => scenery.setSceneMode(e.target.value));
// ---- build area (from the zone file) ----
let boundaryAreas; // undefined: not fetched yet; null: none
async function showBoundary() {
const toggle = document.getElementById('boundaryToggle');
if (boundaryAreas === undefined) {
const b = await api.get(propertyApi + '/boundary').catch(() => null);
boundaryAreas = b && b.success && b.areas.some((a) => a.outline.length >= 3) ? b.areas : null;
if (!boundaryAreas) {
toggle.checked = false;
toggle.disabled = true;
document.getElementById('boundaryWrap').title = 'No build area in this zone\'s file (the server needs client_location)';
} else {
const limit = Math.max(...boundaryAreas.map((a) => a.maxBuildHeight || 0));
document.getElementById('boundaryInfo').textContent = 'The blue line is where the owner may build' + (limit ? ', up to ' + limit + ' high' : '') + ' (from the zone file).';
}
}
// Terrain heights (if fetched) put the line on the ground
// Heights from the textured terrain when shown, else from the height grid if fetched
viewer.setBoundary(toggle.checked ? boundaryAreas : null, chunkTerrain ? null : (terrainData || null));
}
async function loadScene() {
const lod = parseInt(document.getElementById('lodSelect').value, 10);
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();
renderChatPhrases();
await showTerrain();
await showBoundary();
showScenery();
if (keep) viewer.select(keep);
else if (boundaryAreas) viewer.resetView(); // start with the whole plot in view
}
function load() {
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;
loadedLod = null;
setText('propName', p.name || 'Unnamed property');
// Character pages are private, so the showcase names the owner without a link
document.getElementById('propMeta').innerHTML = (p.owner_name ? 'by ' + (showcase ? esc(p.owner_name) : fmt.character(p.owner_id, p.owner_name)) + ' &middot; ' : '') +
esc(p.zone_name || ('Zone ' + p.zone_id)) + ' &middot; ' + p.models.length + ' model' + (p.models.length === 1 ? '' : 's') +
(showcase ? ' &middot; ' + esc(p.reputation) + ' reputation' : '');
const description = document.getElementById('propDescription');
description.textContent = showcase ? (p.description || '') : '';
description.classList.toggle('d-none', !description.textContent);
// An empty property still shows its plot: terrain, scenery and build area
loadScene().then(() => { if (!p.models.length) setText('viewerProgress', 'Nothing placed on this property'); });
});
}
document.getElementById('lodSelect').addEventListener('change', loadScene);
document.getElementById('terrainToggle').addEventListener('change', () => showTerrain().then(showBoundary));
document.getElementById('boundaryToggle').addEventListener('change', showBoundary);
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());
// The whole property as saved, fetched again: models placed as stored, behaviors from the start, nothing selected
document.getElementById('resetPropertyBtn').addEventListener('click', () => {
if (/model=/.test(location.hash)) history.replaceState(null, '', location.pathname + location.search);
viewer.select(null);
player.reset();
document.getElementById('behaviorLog').innerHTML = '<li class="text-body-secondary">Select a model with behaviors and trigger it.</li>';
load();
});
const reprocessModelsBtn = document.getElementById('reprocessModelsBtn');
if (reprocessModelsBtn) reprocessModelsBtn.addEventListener('click', () => {
reprocessModelsBtn.disabled = true;
api.action('/api/ugc/reprocess', { kind: 'model', property: propertyId }).then((d) => toast(d.message, 'success'))
.catch(() => {}).finally(() => { reprocessModelsBtn.disabled = false; });
});
const importModelsFile = document.getElementById('importModelsFile');
if (importModelsFile) {
document.getElementById('importModelsBtn').addEventListener('click', () => importModelsFile.click());
importModelsFile.addEventListener('change', async () => {
const file = importModelsFile.files[0];
if (!file) return;
const lxfml = await file.text();
importModelsFile.value = '';
api.action('/api/properties/' + propertyId + '/import', { lxfml }).then((d) => { toast(d.message, 'success'); load(); }).catch(() => {});
});
}
const removeModelsBtn = document.getElementById('removeModelsBtn');
if (removeModelsBtn) removeModelsBtn.addEventListener('click', () => {
if (window.prompt('This deletes every model on the property. Type the property ID (' + propertyId + ') to confirm:') !== propertyId) return;
removeModelsBtn.disabled = true;
api.action('/api/properties/' + propertyId + '/remove_models', { confirm: propertyId }).then((d) => { toast(d.message, 'success'); load(); })
.catch(() => {}).finally(() => { removeModelsBtn.disabled = false; });
});
document.getElementById('fullscreenBtn').addEventListener('click', () => {
const wrap = document.getElementById('viewerWrap');
if (document.fullscreenElement) document.exitFullscreen();
else if (wrap.requestFullscreen) wrap.requestFullscreen();
});
load();
// Reload when the property changes, unless behaviors are playing
// (Live updates are only there for people signed in)
if (window.Live) Live.on('properties', (e) => { if ((!e.id || e.id === propertyId) && !player.isRunning()) load(); });
</script>
{% endblock %}