1 Commits

Author SHA1 Message Date
Aaron Kimbrell
18bab8d57a fix: place player-built models using their stored position and rotation
DarkflameServer now normalizes UGC model LXFML around the model's own
origin and stores the model's world position and rotation in
properties_contents, the same as for prebuilt models. The property
viewer only applied that transform to prebuilt models, so every
player-built model rendered piled up at the origin.

Pass each UGC model's stored position and rotation to the LDD converter
and apply it to every brick (including flex parts) when building the
scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:39:36 -05:00
9 changed files with 138 additions and 955 deletions

View File

@@ -104,11 +104,6 @@ Please Reference `app/settings_example.py` to see all the variables
* APP_SECRET_KEY (Must be provided)
* APP_DATABASE_URI (Must be provided)
* Everything else is optional and has defaults
* Property model viewer:
* COMPRESS_MODEL_RESPONSES (default `True`): gzip model and brick database files for browsers that support it
* CACHE_COMPRESSED_MODELS (default `False`): store a `.gz` copy next to each cached file instead of compressing on every request (uses more disk space, saves CPU)
* PROPERTY_VIEWER_LOAD_REDRAW (default `none`): property viewer redraw while models load; `none` draws once when all models have loaded, `interval` redraws periodically so models appear progressively
* PROPERTY_VIEWER_REDRAW_INTERVAL_MS (default `500`): minimum time between redraws while loading when PROPERTY_VIEWER_LOAD_REDRAW is `interval`
## Manual Linux Installation

View File

@@ -10,18 +10,7 @@ from flask_user import user_registered, current_user, user_logged_in
from flask_wtf.csrf import CSRFProtect
from flask_apscheduler import APScheduler
from app.luclient import register_luclient_jinja_helpers
from app.compressed_files import register_compressed_files
import pathlib
import shutil
import stat
import tempfile
from contextlib import contextmanager
from zipfile import ZipFile
try:
import fcntl
except ImportError: # Windows
fcntl = None
import msvcrt
from app.commands import (
init_db,
@@ -112,96 +101,26 @@ def create_app():
register_logging(app)
register_settings(app)
register_compressed_files(app)
register_extensions(app)
register_blueprints(app)
register_luclient_jinja_helpers(app)
# Extract the brickdb if it's not already extracted
unpack_brick_cache(app.config["CLIENT_LOCATION"], app.config["CACHE_LOCATION"])
materials = pathlib.Path(f'{app.config["CACHE_LOCATION"]}Materials.xml')
if not materials.is_file():
# unzip the brickdb, and remove the import after
from zipfile import ZipFile
with ZipFile(f"{app.config['CLIENT_LOCATION']}res/brickdb.zip","r") as zip_ref:
zip_ref.extractall(app.config["CACHE_LOCATION"])
del ZipFile
# copy over the brick primitives, and remove the import after
from shutil import copytree
copytree(f"{app.config['CLIENT_LOCATION']}res/brickprimitives", f"{app.config['CACHE_LOCATION']}brickprimitives")
del copytree
return app
BRICK_CACHE_MARKER = ".brickdb_unpacked"
@contextmanager
def _exclusive_lock(lock_path):
"""Cross-process exclusive lock, held for the duration of the block."""
with open(lock_path, "a+b") as lock_file:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
else: # Windows
lock_file.seek(0)
while True:
try:
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError:
time.sleep(0.1)
try:
yield
finally:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
else:
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
def _make_owner_writable(path):
"""Add owner write permission to a directory tree (copytree keeps source modes)."""
for root, dirs, files in os.walk(path):
for name in [root] + [os.path.join(root, d) for d in dirs]:
os.chmod(name, os.stat(name).st_mode | stat.S_IWUSR | stat.S_IXUSR)
def unpack_brick_cache(client_location, cache_location):
"""Unpack brickdb.zip and brickprimitives into the cache exactly once.
Several gunicorn workers run create_app() at the same time, so the
unpack is serialized with a file lock, extracted into a temporary
directory and moved into place, and only marked complete at the end.
A crashed or partial unpack is redone on the next start.
"""
cache = pathlib.Path(cache_location)
marker = cache / BRICK_CACHE_MARKER
if marker.is_file():
return
cache.mkdir(parents=True, exist_ok=True)
with _exclusive_lock(cache / ".brickdb_unpack.lock"):
# another process may have finished while we waited for the lock
if marker.is_file():
return
tmp = pathlib.Path(tempfile.mkdtemp(prefix=".brickdb_tmp_", dir=cache))
try:
with ZipFile(f"{client_location}res/brickdb.zip", "r") as zip_ref:
zip_ref.extractall(tmp)
# copy file contents only: the client files are often read-only,
# and read-only directories can't be moved into place or cleaned up
shutil.copytree(
f"{client_location}res/brickprimitives",
tmp / "brickprimitives",
copy_function=shutil.copyfile
)
_make_owner_writable(tmp)
for entry in tmp.iterdir():
target = cache / entry.name
# leftovers from an older or interrupted unpack get replaced
if target.is_dir() and not target.is_symlink():
_make_owner_writable(target)
shutil.rmtree(target)
elif target.exists() or target.is_symlink():
target.unlink()
os.replace(entry, target)
finally:
if tmp.exists():
_make_owner_writable(tmp)
shutil.rmtree(tmp)
marker.touch()
def register_extensions(app):
"""Register extensions for Flask app
@@ -367,26 +286,6 @@ def register_settings(app):
app.config['ENABLE_CHAR_XML_UPLOAD']
)
# Property model viewer compression (see settings_example.py)
for key, default in (
("COMPRESS_MODEL_RESPONSES", True), ("CACHE_COMPRESSED_MODELS", False)
):
value = os.getenv(key, app.config.get(key, default))
if isinstance(value, str):
value = value.strip().lower() in ("1", "true", "yes", "on")
app.config[key] = bool(value)
# Property model viewer redraw while models load (see settings_example.py)
key = "PROPERTY_VIEWER_LOAD_REDRAW"
redraw = str(os.getenv(key, app.config.get(key, "none"))).strip().lower()
app.config[key] = redraw if redraw in ("none", "interval") else "none"
key = "PROPERTY_VIEWER_REDRAW_INTERVAL_MS"
try:
interval = int(os.getenv(key, app.config.get(key, 500)))
except (TypeError, ValueError):
interval = 500
app.config[key] = max(interval, 16)
if "CLIENT_LOCATION" not in app.config:
app.config['CLIENT_LOCATION'] = 'app/luclient/'
app.config['CLIENT_LOCATION'] = os.getenv(

View File

@@ -17,13 +17,11 @@ from app.models import (
BugReport,
AccountInvitation,
db,
Friends,
CommandLog,
CharacterXML
Friends
)
from app import gm_level, log_audit
from app.forms import EditGMLevelForm, EditEmailForm
from sqlalchemy import or_, inspect, text, bindparam
from sqlalchemy import or_
accounts_blueprint = Blueprint('accounts', __name__)
@@ -149,114 +147,55 @@ def mute(id, days=0):
@gm_level(9)
def delete(id):
account = Account.query.filter(Account.id == id).first()
if account is None:
flash("Account not found", "danger")
return redirect(url_for("main.index"))
message = f"Deleted Account ({account.id}){account.username}"
try:
_delete_account_data(account)
except Exception:
db.session.rollback()
current_app.logger.exception(f"Failed to delete account {id}")
flash(f"Failed to delete Account ({account.id}){account.username}", "danger")
return redirect(url_for("accounts.view", id=id))
chars = CharacterInfo.query.filter(CharacterInfo.account_id == id).all()
for char in chars:
activities = ActivityLog.query.filter(
ActivityLog.character_id == char.id
).all()
for activity in activities:
activity.delete()
lb_entries = Leaderboard.query.filter(
Leaderboard.character_id == char.id
).all()
for lb_entry in lb_entries:
lb_entry.delete()
mails = Mail.query.filter(Mail.receiver_id == char.id).all()
for mail in mails:
mail.delete()
props = Property.query.filter(Property.owner_id == char.id).all()
for prop in props:
prop_contents = PropertyContent.query.filter(
PropertyContent.property_id == prop.id
).all()
for prop_content in prop_contents:
if prop_content.lot == "14":
UGC.query.filter(UGC.id == prop.ugc_id).first().delete()
prop_content.delete()
prop.delete()
friends = Friends.query.filter(
or_(Friends.player_id == char.id, Friends.friend_id == char.id)
).all()
for friend in friends:
friend.delete()
char.delete()
# This is for GM stuff, it will be permnently delete logs
bugs = BugReport.query.filter(BugReport.resoleved_by_id == id).all()
for bug in bugs:
bug.delete()
audits = AuditLog.query.filter(AuditLog.account_id == id).all()
for audit in audits:
audit.delete()
invites = AccountInvitation.query.filter(
AccountInvitation.invited_by_user_id == id).all()
for invite in invites:
invite.delete()
account.delete()
flash(message, "danger")
log_audit(message)
return redirect(url_for("main.index"))
def _bulk_delete(query):
# Bulk deletes bypass the ORM relationship handling, which would otherwise
# try to NULL out non-nullable foreign keys (e.g. ugc.character_id)
# instead of deleting the dependent rows.
query.delete(synchronize_session=False)
def _delete_account_data(account):
"""Delete an account and every row that references it or its characters.
The DLU schema uses ON DELETE RESTRICT for most of these foreign keys,
so dependents have to be removed explicitly, children first, and all
in one transaction so a failure leaves nothing half-deleted.
"""
char_ids = [
c.id for c in CharacterInfo.query.with_entities(CharacterInfo.id)
.filter(CharacterInfo.account_id == account.id).all()
]
clone_ids = [
c.prop_clone_id for c in CharacterInfo.query.with_entities(CharacterInfo.prop_clone_id)
.filter(CharacterInfo.account_id == account.id).all()
]
existing_tables = set(inspect(db.engine).get_table_names())
if char_ids:
prop_ids = [
p.id for p in Property.query.with_entities(Property.id).filter(
or_(Property.owner_id.in_(char_ids), Property.clone_id.in_(clone_ids))
).all()
]
ugc_ids = [
u.id for u in UGC.query.with_entities(UGC.id).filter(
or_(UGC.account_id == account.id, UGC.character_id.in_(char_ids))
).all()
]
# property contents placed on this account's properties, or using its UGC models
content_filter = []
if prop_ids:
content_filter.append(PropertyContent.property_id.in_(prop_ids))
if ugc_ids:
content_filter.append(PropertyContent.ugc_id.in_(ugc_ids))
if content_filter:
_bulk_delete(PropertyContent.query.filter(or_(*content_filter)))
if prop_ids:
_bulk_delete(Property.query.filter(Property.id.in_(prop_ids)))
if "ugc_modular_build" in existing_tables:
db.session.execute(
text("DELETE FROM ugc_modular_build WHERE character_id IN :ids")
.bindparams(bindparam("ids", expanding=True)),
{"ids": char_ids}
)
if "ignore_list" in existing_tables:
db.session.execute(
text("DELETE FROM ignore_list WHERE player_id IN :ids OR ignored_player_id IN :ids")
.bindparams(bindparam("ids", expanding=True)),
{"ids": char_ids}
)
_bulk_delete(ActivityLog.query.filter(ActivityLog.character_id.in_(char_ids)))
_bulk_delete(Leaderboard.query.filter(Leaderboard.character_id.in_(char_ids)))
_bulk_delete(Mail.query.filter(Mail.receiver_id.in_(char_ids)))
_bulk_delete(CommandLog.query.filter(CommandLog.character_id.in_(char_ids)))
_bulk_delete(Friends.query.filter(
or_(Friends.player_id.in_(char_ids), Friends.friend_id.in_(char_ids))
))
_bulk_delete(CharacterXML.query.filter(CharacterXML.id.in_(char_ids)))
# UGC can reference the account even without a (remaining) character
_bulk_delete(UGC.query.filter(
or_(UGC.account_id == account.id, UGC.character_id.in_(char_ids))
if char_ids else UGC.account_id == account.id
))
if char_ids:
_bulk_delete(CharacterInfo.query.filter(CharacterInfo.id.in_(char_ids)))
if "player_cheat_detections" in existing_tables:
db.session.execute(
text("DELETE FROM player_cheat_detections WHERE account_id = :id"),
{"id": account.id}
)
if "accounts_rewardcodes" in existing_tables:
db.session.execute(
text("DELETE FROM accounts_rewardcodes WHERE account_id = :id"),
{"id": account.id}
)
# This is for GM stuff, it will be permnently delete logs
_bulk_delete(BugReport.query.filter(BugReport.resoleved_by_id == account.id))
_bulk_delete(AuditLog.query.filter(AuditLog.account_id == account.id))
_bulk_delete(AccountInvitation.query.filter(AccountInvitation.invited_by_user_id == account.id))
_bulk_delete(Account.query.filter(Account.id == account.id))
db.session.commit()
@accounts_blueprint.route('/pass_reset/<id>', methods=['GET', 'POST'])
@login_required
@gm_level(9)

View File

@@ -1,220 +0,0 @@
"""Helpers for serving model / brick database files with validators and gzip.
Responses carry a strong ETag (derived from the source file's mtime and size,
or from a content hash for in-memory data) plus Last-Modified where available,
and honour conditional requests so repeat visits get cheap 304 responses.
When the client accepts gzip and ``COMPRESS_MODEL_RESPONSES`` is enabled the
body is gzip encoded. With ``CACHE_COMPRESSED_MODELS`` enabled a ``.gz``
copy is
stored on disk (regenerated whenever the source is newer) so no CPU is spent
per request; otherwise the body is compressed in memory at a fast level.
"""
import gzip
import hashlib
import mimetypes
import os
import pathlib
import tempfile
from datetime import datetime, timezone
from flask import Response, current_app, g, request, send_file
from flask.sessions import SecureCookieSessionInterface
# Fast level for on-the-fly compression, better level for stored copies
# (stored copies are only written once).
ON_THE_FLY_LEVEL = 1
STORED_LEVEL = 6
def _compression_enabled():
return bool(current_app.config.get("COMPRESS_MODEL_RESPONSES", True))
def _store_compressed():
return bool(current_app.config.get("CACHE_COMPRESSED_MODELS", False))
def _client_accepts_gzip():
return _compression_enabled() and request.accept_encodings["gzip"] > 0
def _gz_path_for(path):
"""Location of the stored gzip copy for ``path``.
Files inside the cache directory get a sibling ``.gz``; files that live
elsewhere (e.g. the client's res/ folder, which may be read-only or shared)
get a copy under ``<CACHE_LOCATION>/compressed/``.
"""
cache_root = pathlib.Path(current_app.config["CACHE_LOCATION"]).resolve()
path = path.resolve()
try:
path.relative_to(cache_root)
return path.with_name(path.name + ".gz")
except ValueError:
digest = hashlib.sha1(str(path).encode()).hexdigest()
return cache_root / "compressed" / f"{digest}-{path.name}.gz"
def _ensure_gz(path, src_stat):
"""Create/refresh the stored gzip copy of ``path`` and return its path."""
gz_path = _gz_path_for(path)
try:
if gz_path.stat().st_mtime_ns >= src_stat.st_mtime_ns:
return gz_path
except FileNotFoundError:
pass
gz_path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "rb") as src:
data = gzip.compress(src.read(), compresslevel=STORED_LEVEL, mtime=0)
# write atomically so concurrent workers never serve a partial file
fd, tmp = tempfile.mkstemp(
dir=gz_path.parent, prefix=".tmp-", suffix=".gz"
)
try:
with os.fdopen(fd, "wb") as out:
out.write(data)
os.chmod(tmp, 0o644) # mkstemp creates 0600, match other cache files
os.replace(tmp, gz_path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
return gz_path
def _skip_session_cookie():
g.compressed_files_user_independent = True
class _SessionInterface(SecureCookieSessionInterface):
"""Skip refreshing the session cookie for user independent files.
Flask-User refreshes the session cookie on every request, and Flask adds
"Vary: Cookie" whenever it sets the cookie. As the cookie value changes on
every response, the browser then has to revalidate every cached copy,
defeating max-age. These files don't depend on the session, so there is
nothing to save for them.
"""
def save_session(self, app, session, response):
if g.get("compressed_files_user_independent"):
return None
return super().save_session(app, session, response)
def register_compressed_files(app):
if type(app.session_interface) is SecureCookieSessionInterface:
app.session_interface = _SessionInterface()
def _finish(response, etag, last_modified, cache_control, vary):
response.set_etag(etag)
if last_modified is not None:
response.last_modified = last_modified
response.headers["Cache-Control"] = cache_control
if vary:
response.vary.add("Accept-Encoding")
return response.make_conditional(request)
def _not_modified(etag, last_modified, cache_control, vary, mimetype):
"""Answer a matching conditional request without reading/compressing."""
if etag in request.if_none_match:
return _finish(
Response(status=304, mimetype=mimetype),
etag, last_modified, cache_control, vary
)
return None
def serve_file(path, mimetype, cache_control, user_independent=False):
"""Serve a file from disk with validators and optional gzip encoding.
``user_independent`` marks content that does not depend on the logged in
user; the session cookie is not refreshed on these responses so no
``Vary: Cookie`` is added (see ``_SessionInterface``).
"""
if user_independent:
_skip_session_cookie()
# absolute: send_file resolves relative paths against the app package
path = pathlib.Path(path).resolve()
if mimetype is None:
mimetype = (
mimetypes.guess_type(path.name)[0] or "application/octet-stream"
)
st = path.stat()
base_etag = f"{st.st_mtime_ns:x}-{st.st_size:x}"
last_modified = datetime.fromtimestamp(int(st.st_mtime), tz=timezone.utc)
vary = _compression_enabled()
if not _client_accepts_gzip():
response = send_file(
path, mimetype=mimetype, etag=base_etag,
last_modified=last_modified, conditional=True
)
response.headers["Cache-Control"] = cache_control
if vary:
response.vary.add("Accept-Encoding")
return response
etag = base_etag + "-gz"
early = _not_modified(etag, last_modified, cache_control, vary, mimetype)
if early is not None:
return early
if _store_compressed():
gz_path = _ensure_gz(path, st)
response = send_file(
gz_path, mimetype=mimetype, etag=etag,
last_modified=last_modified, conditional=True
)
response.headers["Cache-Control"] = cache_control
response.headers["Content-Encoding"] = "gzip"
response.vary.add("Accept-Encoding")
return response
with open(path, "rb") as src:
data = gzip.compress(
src.read(), compresslevel=ON_THE_FLY_LEVEL, mtime=0
)
response = Response(data, mimetype=mimetype)
response.headers["Content-Encoding"] = "gzip"
return _finish(response, etag, last_modified, cache_control, vary)
def serve_bytes(data, mimetype, cache_control, etag_seed=None):
"""Serve in-memory data (e.g. UGC lxfml from the database).
``etag_seed`` should be cheap, stable bytes identifying the content (for
example the stored compressed blob); defaults to ``data``. ``data`` may
be a callable producing the body, so a 304 can skip building it (this
requires ``etag_seed``).
"""
if callable(data):
if etag_seed is None:
data = data()
if isinstance(data, str):
data = data.encode()
base_etag = hashlib.sha1(
etag_seed if etag_seed is not None else data
).hexdigest()
vary = _compression_enabled()
gz = _client_accepts_gzip()
etag = base_etag + ("-gz" if gz else "")
early = _not_modified(etag, None, cache_control, vary, mimetype)
if early is not None:
return early
if callable(data):
data = data()
if isinstance(data, str):
data = data.encode()
if gz:
data = gzip.compress(data, compresslevel=ON_THE_FLY_LEVEL, mtime=0)
response = Response(data, mimetype=mimetype)
if gz:
response.headers["Content-Encoding"] = "gzip"
return _finish(response, etag, None, cache_control, vary)

View File

@@ -10,7 +10,6 @@ from flask import (
)
from flask_user import login_required
from app.models import CharacterInfo
from app.compressed_files import serve_file
import glob
import os
from wand import image
@@ -165,12 +164,7 @@ def dir_listing(req_path):
# Check if path is a file and serve
if rel_path.is_file():
# Thousands of small, effectively immutable brick primitives are
# fetched
# per viewer load; a modest max-age avoids a 304 round trip for each.
return serve_file(
rel_path, None, "private, max-age=86400", user_independent=True
)
return send_file(rel_path)
else:
return abort(404)

View File

@@ -1,4 +1,3 @@
import re
from flask import (
render_template,
Blueprint,
@@ -16,12 +15,9 @@ import time
from app.models import Property, db, UGC, CharacterInfo, PropertyContent, Account, Mail
from app import gm_level, log_audit
from app.luclient import query_cdclient
from app.compressed_files import serve_bytes, serve_file
from app.forms import RejectPropertyForm
import zlib
import json
import hashlib
import app.pylddlib as ldd
import pathlib
@@ -308,30 +304,10 @@ def view_model(id, lod):
return render_template(
'ldd/ldd.html.j2',
content=formatted_data,
lod=lod,
**viewer_redraw_options()
lod=lod
)
def viewer_redraw_options():
"""Redraw-while-loading settings for the model viewer template.
?redraw=none|interval and ?redraw_ms=N override the config for testing.
"""
config = current_app.config
mode = request.args.get(
"redraw", config.get("PROPERTY_VIEWER_LOAD_REDRAW", "none")
)
if mode not in ("none", "interval"):
mode = "none"
interval = config.get("PROPERTY_VIEWER_REDRAW_INTERVAL_MS", 500)
try:
interval = max(int(request.args.get("redraw_ms", interval)), 16)
except ValueError:
pass
return {"load_redraw": mode, "redraw_interval_ms": interval}
property_center = {
1150: "(-17, 432, -60)",
1151: "(0, 455, -110)",
@@ -391,9 +367,7 @@ def view_models(id, lod):
property_data=property_data,
content=consolidated_list,
center=property_center[property_data.zone_id],
lod=lod,
ugc_batch_url=url_for('properties.get_ugc_models', id=id),
**viewer_redraw_options()
lod=lod
)
@@ -403,53 +377,13 @@ def get_model(id, file_format, lod):
content = PropertyContent.query.filter(PropertyContent.id == id).first()
if not(0 <= int(lod) <= 2):
abort(404)
# Large model files: let the browser keep a copy but always revalidate it
# (cheap 304 via the ETag) instead of storing long-lived entries.
cache_control = "private, no-cache"
if content.lot == 14: # ugc model
ugc_data = UGC.query.filter(UGC.id == content.ugc_id).first()
# decompress lazily so a matching If-None-Match skips the work
return serve_bytes(
lambda: decompress(ugc_data.lxfml), 'text/xml', cache_control,
etag_seed=ugc_data.lxfml
)
response = ugc(content)[0]
else: # prebuilt model
path = prebuilt_path(content, file_format, lod)
if not isinstance(path, pathlib.Path):
return path
return serve_file(path, 'text/xml', cache_control)
response = prebuilt(content, file_format, lod)[0]
@property_blueprint.route('/get_ugc_models/<id>', methods=['GET'])
@login_required
def get_ugc_models(id):
"""All UGC (brick-built) LXFML of a property in one JSON response.
Returns ``{"<property content id>": "<lxfml>", ...}`` so the viewer does
not need one request per model (a property can have hundreds).
"""
rows = db.session.query(PropertyContent.id, UGC.lxfml).join(
UGC, UGC.id == PropertyContent.ugc_id
).filter(
PropertyContent.property_id == id, PropertyContent.lot == 14
).order_by(PropertyContent.id).all()
# ETag over the whole set: content ids plus the stored (compressed) blobs
seed = hashlib.sha1()
for content_id, lxfml in rows:
seed.update(f"{content_id}:{len(lxfml)}:".encode())
seed.update(lxfml)
def body():
return json.dumps({
str(content_id): decompress(lxfml).decode("utf-8", "replace")
for content_id, lxfml in rows
})
return serve_bytes(
body, 'application/json', "private, no-cache",
etag_seed=seed.digest()
)
response.headers.set('Content-Type', 'text/xml')
return response
@property_blueprint.route('/download_model/<id>', methods=['GET'])
@@ -460,7 +394,7 @@ def download_model(id):
if content.lot == 14: # ugc model
response, filename = ugc(content)
else: # prebuilt model
response, filename = prebuilt(content, "lxfml", 0)
response, filename = prebuilt(content, "lxfml")
response.headers.set('Content-Type', 'attachment/xml')
response.headers.set(
@@ -488,18 +422,7 @@ def decompress(data):
pos += length
return out
def render_asset_basename(render_asset):
"""Return the lowercase file name without extension of a client asset path.
render_asset paths in the cdclient mix separators: single backslashes
(mesh\\ftcommunityitems\\x.nif), escaped double backslashes, forward
slashes, or combinations of those, and mixed case.
"""
name = re.split(r"[\\/]+", render_asset.strip())[-1]
return name.split(".")[0].lower()
def prebuilt_path(content, file_format, lod):
def prebuilt(content, file_format, lod):
# translate LOT to component id
# we need to get a type of 2 for the render component to find the filename
render_component_id = query_cdclient(
@@ -515,15 +438,21 @@ def prebuilt_path(content, file_format, lod):
)
# if we have a valie filename, coerce it
if filename and filename[0]:
filename = render_asset_basename(filename[0])
if not filename:
abort(404)
if filename:
filename = filename[0].split("\\\\")[-1].lower().split(".")[0]
if "/" in filename:
filename = filename.split("/")[-1].lower()
else:
return f"No filename for LOT {content.lot}"
# if we just want the lxfml, fine t and return it
lxfml = pathlib.Path(f'{current_app.config["CLIENT_LOCATION"]}res/BrickModels/{filename.split(".")[0]}.lxfml')
if file_format == "lxfml":
return lxfml
with open(lxfml, 'r') as file:
lxfml_data = file.read()
response = make_response(lxfml_data)
# else we handle getting the files for lddviewer
elif file_format in ["obj", "mtl"]:
# check to see if the file exists
@@ -535,16 +464,13 @@ def prebuilt_path(content, file_format, lod):
ldd.main(str(lxfml.as_posix()), str(cache.with_suffix("").as_posix()), lod) # convert to OBJ
except Exception as e:
current_app.logger.error(f"ERROR on {cache}:\n {e}")
return cache
# then just read it
with open(str(cache.as_posix()), 'r') as file:
cache_data = file.read()
# and serve it
response = make_response(cache_data)
else:
raise(Exception("INVALID FILE FORMAT"))
def prebuilt(content, file_format, lod):
path = prebuilt_path(content, file_format, lod)
if not isinstance(path, pathlib.Path):
return path
with open(path, 'r') as file:
response = make_response(file.read())
return response, path.name
return response, f"{filename}.{file_format}"

View File

@@ -139,7 +139,7 @@ def currency_graph(start, end):
else:
data.append(0)
color = "#" + character.name.encode("utf-8").hex()[1:7]
if data and max(data) > 10000:
if max(data) > 10000:
datasets.append({
"label": character.name,
"data": data,
@@ -194,7 +194,7 @@ def uscore_graph(start, end):
else:
data.append(0)
color = "#" + character.name.encode("utf-8").hex()[1:7]
if data and max(data) > 1000:
if max(data) > 1000:
datasets.append({
"label": character.name,
"data": data,

View File

@@ -71,23 +71,3 @@ RECAPTCHA_PRIVATE_KEY = ''
# RECAPTCHA_API_SERVER = ''
# RECAPTCHA_PARAMETERS = ''
RECAPTCHA_DATA_ATTRS = {'theme': 'white', 'size': 'invisible'}
# Property model viewer response compression
# Gzip model (OBJ/MTL/LXFML) and LDD brick database responses for clients that
# accept it. Set to False to always send uncompressed files.
COMPRESS_MODEL_RESPONSES = True
# When True, a gzip copy is written to disk next to each cached file the first
# time it is served (regenerated if the source file is newer) and served
# directly: uses more disk space, but no CPU per request.
# When False, responses are compressed in memory on every request at a fast
# compression level and nothing extra is stored.
CACHE_COMPRESSED_MODELS = False
# Property model viewer redraw while models are loading
# 'none': do not redraw as individual models finish loading; draw once when
# all loads have completed (or failed), then only on camera move/resize.
# 'interval': redraw at most every PROPERTY_VIEWER_REDRAW_INTERVAL_MS while
# loading so models appear progressively, then a final draw.
# Can be overridden per page for testing with ?redraw=none|interval&redraw_ms=N
PROPERTY_VIEWER_LOAD_REDRAW = 'none'
PROPERTY_VIEWER_REDRAW_INTERVAL_MS = 500

View File

@@ -277,13 +277,11 @@
this.xmldata = ''
}
AddScene(file){
// placement: optional THREE.Matrix4 that places this model in the property. DLU stores player-built
// (UGC) models normalized around their own origin, with the position and rotation in properties_contents.
AddScene(file, placement = null){
let lxfmlfile = new DBURLFile(file,file)
this.AddSceneData(lxfmlfile.read())
}
AddSceneData(xmldata){
this.xmldata = xmldata
this.xmldata = lxfmlfile.read()
if (this.xmldata === ""){
alert(this.xmldata)
@@ -309,7 +307,9 @@
for (let j = 0; j < childnodes.length ;j++) {
let childnode = childnodes[j]
if (childnode.nodeName == 'Brick'){
this.Bricks.push(new Brick(childnode))
let brick = new Brick(childnode)
brick.placement = placement
this.Bricks.push(brick)
}
}
}
@@ -525,8 +525,6 @@
constructor(){
this.allMaterials = ''
// brick geometry is shared by every UGC model converted by this converter
this.geometryCache = {}
this.scene = new Scene()
}
@@ -537,33 +535,21 @@
}
}
LoadScene(filename){
LoadScene(filename, placement = null){
if(this.database.initok){
// Each LXFML file is its own model with its own placement,
// so start from a fresh scene instead of accumulating bricks
this.scene = new Scene()
this.scene.AddScene(filename)
this.scene.AddScene(filename, placement)
}
}
// Builds the meshes for the currently loaded LXFML scene.
// The brick transforms in a UGC LXFML are relative to the model's own origin,
// so the meshes are collected into a group which is then placed in the world
// using the position/rotation stored for the model (properties_contents).
Export(filename, positions = []){
let modelGroup = new THREE.Group()
// reuse the Materials.xml parsed once in LoadDBURL
if (!this.allMaterials) {
this.allMaterials = new Materials(this.database.filelist['Materials.xml'].read())
}
let allMaterials = this.allMaterials
Export(filename){
let allMaterials = new Materials(this.database.filelist['Materials.xml'].read())
let invert = new Matrix3D()
//invert.n33 = -1 //uncomment to invert the Z-Axis
let indexOffset = 1
let textOffset = 1
let usedmaterials = []
let geometriecache = this.geometryCache
let geometriecache = {}
let total = this.scene.Bricks.length
let current = 0
@@ -626,6 +612,11 @@
n14, n24 ,n34, n44);
}
// Move the brick from model space to its place on the property
if (bri.placement){
m.premultiply(bri.placement)
}
let decoCount = 0
for (const [partindex, part] of geo.Parts.entries()){
@@ -735,26 +726,15 @@
let mesh = new THREE.Mesh(geometry, materials);
mesh.matrixAutoUpdate = false
mesh.matrix = m
modelGroup.add(mesh);
{% if not center %}
brick_pos.setFromMatrixPosition( m );
{% endif %}
scene.add(mesh);
// let vnh = new VertexNormalsHelper( mesh, 5 );
// scene.add( vnh );
}
}
}
if (positions.length === 0) {
positions = [{x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0, rw: 1}]
}
for (const [j, pos] of positions.entries()) {
let model = (j === 0) ? modelGroup : modelGroup.clone()
model.position.set(pos.x, pos.y, pos.z)
model.quaternion.set(pos.rx, pos.ry, pos.rz, pos.rw)
model.updateMatrixWorld(true)
scene.add(model)
{% if not center %}
new THREE.Box3().setFromObject(model).getCenter(brick_pos)
{% endif %}
}
}
}
@@ -965,25 +945,7 @@
this.name = name
}
// Fetch the file asynchronously (once) and keep it for read().
load() {
if (!this.loading) {
this.loading = fetch(this.urlHandle).then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status + ' for ' + response.url)
return response.arrayBuffer()
}).then((buffer) => {
this.content = bytesToUserDefinedString(buffer)
}).catch((error) => {
console.log('Error loading', this.urlHandle, error)
this.content = ''
})
}
return this.loading
}
read() {
// Prefetched by load(): no request needed
if (this.content !== undefined) return this.content
let fileContent
let self = this;
let xhr = new XMLHttpRequest();
@@ -1015,72 +977,6 @@
}
// Same string the synchronous XHR with the 'x-user-defined' charset hack
// produced: ASCII bytes as-is, bytes >= 0x80 mapped to U+F780..U+F7FF.
function bytesToUserDefinedString(buffer) {
const bytes = new Uint8Array(buffer)
const chunk = 8192
let out = ''
for (let i = 0; i < bytes.length; i += chunk) {
const part = bytes.subarray(i, i + chunk)
const codes = new Array(part.length)
for (let j = 0; j < part.length; j++) {
const b = part[j]
codes[j] = b < 0x80 ? b : 0xF700 + b
}
out += String.fromCharCode.apply(null, codes)
}
return out
}
// Run fn over items with at most `limit` in flight at a time.
async function runLimited(items, limit, fn) {
let next = 0
async function worker() {
while (next < items.length) {
const item = items[next++]
await fn(item)
}
}
const workers = []
for (let i = 0; i < Math.min(limit, items.length); i++) workers.push(worker())
await Promise.all(workers)
}
const FETCH_CONCURRENCY = 8
// Asynchronous version of DBURLReader: lists the brick database and
// prefetches info.xml / Materials.xml without blocking the main thread.
async function loadDBURLReader(dburl) {
const reader = Object.create(DBURLReader.prototype)
reader.filelist = {}
reader.initok = false
reader.location = dburl
reader.dbinfo = ''
async function parse(url) {
const response = await fetch(url)
if (!response.ok) {
console.log('HTTP error', response.status, response.statusText)
return
}
const data = await response.json()
const subdirs = []
for (const obj of data) {
if (obj.type == 'directory') subdirs.push(url + obj.name + '/')
else if (obj.type == 'file') reader.filelist[obj.name] = new DBURLFile(url + obj.name, obj.name)
else console.log('Strange object parsed: ' + obj.type)
}
await Promise.all(subdirs.map(parse))
}
await parse(dburl)
if (reader.fileexist('Materials.xml') && reader.fileexist('info.xml')) {
await Promise.all([reader.filelist['Materials.xml'].load(), reader.filelist['info.xml'].load()])
reader.dbinfo = new DBinfo(reader.filelist['info.xml'].read())
reader.initok = true
}
return reader
}
class DBURLReader {
constructor(dburl) {
this.filelist = {};
@@ -1146,130 +1042,38 @@
//Start
// Player-built models with the position and rotation stored for them in properties_contents
let lxfml_file_list = [
{% for model in content %}
{% if model.lot == 14 %}
{
"id": {{ model.id|string|tojson }},
"url": "{{url_for('properties.get_model', id=model.id, file_format='lxfml', lod=lod)}}",
"pos": {{ model.pos|tojson }}
},
"pos": {{ model.pos[0]|tojson }}
}{{ ", " if not loop.last else "" }}
{% endif %}
{% endfor %}
]
// All UGC LXFML of a property in one request (view_models); otherwise per model
const ugcBatchUrl = {{ ugc_batch_url|default(none)|tojson }}
if (lxfml_file_list.length > 0) {
let ldddburl = FindDBURL()
// Load the brick-built (UGC) models without blocking the page: fetch the
// LXFML (batched), then each needed brick primitive once with bounded
// concurrency, then build the meshes in small slices so the progress badge
// keeps updating.
async function loadUGCModels() {
if (lxfml_file_list.length === 0) return
let built = 0
try {
setProgressDetail('brick database')
const database = await loadDBURLReader('/luclient/ldddb/')
if (!database.initok) {
alert("LDD database not available. Please look for LEGO-Digital-Designer database.")
throw new Error('LDD database not available')
if (ldddburl) {
let converter = new Converter()
converter.LoadDBURL(ldddburl)
for (let i = 0; i < lxfml_file_list.length; i++) {
let pos = lxfml_file_list[i].pos
let placement = new THREE.Matrix4().compose(
new THREE.Vector3(pos.x, pos.y, pos.z),
new THREE.Quaternion(pos.rx, pos.ry, pos.rz, pos.rw),
new THREE.Vector3(1, 1, 1)
)
converter.LoadScene(lxfml_file_list[i].url, placement)
}
const converter = new Converter()
converter.database = database
converter.allMaterials = new Materials(database.filelist['Materials.xml'].read())
converter.Export('test.webgl')
setProgressDetail('model files')
let texts
if (ugcBatchUrl) {
const response = await fetch(ugcBatchUrl)
if (!response.ok) throw new Error('HTTP ' + response.status + ' for ' + ugcBatchUrl)
const data = await response.json()
texts = lxfml_file_list.map(function (m) { return data[m.id] })
} else {
texts = new Array(lxfml_file_list.length)
await runLimited(lxfml_file_list.map(function (m, i) { return i }), FETCH_CONCURRENCY, async function (i) {
try {
const response = await fetch(lxfml_file_list[i].url)
if (response.ok) texts[i] = bytesToUserDefinedString(await response.arrayBuffer())
} catch (error) { console.log(error) }
})
}
let lastYield = performance.now()
async function maybeYield() {
if (performance.now() - lastYield > 30) {
await new Promise(function (resolve) { setTimeout(resolve) })
lastYield = performance.now()
}
}
// parse the scenes and collect the bricks they use
const scenes = []
for (let i = 0; i < texts.length; i++) {
let parsed = null
if (texts[i]) {
try { parsed = new Scene(); parsed.AddSceneData(texts[i]) } catch (error) { console.log(error); parsed = null }
}
scenes.push(parsed)
await maybeYield()
}
const files = new Set()
const lod = {{ lod }}
for (const parsed of scenes) {
if (!parsed) continue
for (const brick of parsed.Bricks) {
for (const part of brick.Parts) {
let n = 0
let location = `brickprimitives/lod${lod}/${part.designID}.g`
while (location in database.filelist) {
files.add(database.filelist[location])
n += 1
location = `brickprimitives/lod${lod}/${part.designID}.g${n}`
}
const primitive = database.filelist[`Primitives/${part.designID}.xml`]
if (primitive) files.add(primitive)
}
}
}
// each primitive is fetched once (Set + DBURLFile.load() caching)
const fileList = Array.from(files)
let fetched = 0
setProgressDetail('bricks 0/' + fileList.length)
await runLimited(fileList, FETCH_CONCURRENCY, async function (file) {
await file.load()
fetched += 1
setProgressDetail('bricks ' + fetched + '/' + fileList.length)
})
setProgressDetail('')
// build the meshes
for (let i = 0; i < scenes.length; i++) {
built += 1
if (!scenes[i]) { modelDone(true); continue }
try {
converter.scene = scenes[i]
converter.Export('test.webgl', lxfml_file_list[i].pos)
modelDone(false)
} catch (error) {
console.log(error)
modelDone(true)
}
if (performance.now() - lastYield > 30) {
loadProgressRender()
await maybeYield()
}
}
} catch (error) {
console.log(error)
// whatever was not built counts as failed
for (; built < lxfml_file_list.length; built++) modelDone(true)
}
setProgressDetail('')
{% if not center and not content|length > 1 %}
// the camera follows the brick-built model, now that its position is known
camera.position.set( brick_pos.x + 200, brick_pos.y + 300, brick_pos.z + 200 );
controls.update()
{% endif %}
else {
alert("LDD database not available. Please look for LEGO-Digital-Designer database.")
}
}
const onProgress = function ( xhr ) {
@@ -1285,120 +1089,8 @@
const onError = function (error) {
console.log(error)
loadProgressRender()
};
// Render on demand: only draw when something changed (a load finished,
// the camera moved or the window resized), at most once per animation frame.
let renderPending = false
function requestRender() {
if (renderPending) return
renderPending = true
requestAnimationFrame(function () {
renderPending = false
renderer.render(scene, camera)
})
}
// Redraw behaviour while models are still loading (PROPERTY_VIEWER_LOAD_REDRAW):
// 'none' - do not redraw for finished loads; draw once when everything is done
// 'interval' - redraw at most every PROPERTY_VIEWER_REDRAW_INTERVAL_MS while loading
// Camera moves and resizes always redraw. The final draw also happens when loads fail.
const loadRedraw = {{ load_redraw|default('none')|tojson }}
const redrawIntervalMs = {{ redraw_interval_ms|default(500)|int }}
let modelsLoading = true
let lastLoadRender = 0
let loadRenderTimer = null
// Progress indicator (a DOM element, so updating it never redraws the canvas).
// Counts models: brick-built (lxfml) models count once their meshes are built,
// pre-built models when their OBJ loads or their MTL/OBJ fails.
const loadingIndicator = document.createElement('div')
loadingIndicator.id = 'loading-progress'
loadingIndicator.style.cssText = 'position:fixed;top:8px;left:8px;padding:4px 8px;background:rgba(20,20,20,0.93);color:#fff;font:bold 14px sans-serif;border:1px solid rgba(255,255,255,0.35);border-radius:4px;text-shadow:0 1px 2px rgba(0,0,0,0.8);box-shadow:0 1px 4px rgba(0,0,0,0.5);z-index:10;pointer-events:none;transition:opacity 1s'
document.body.appendChild(loadingIndicator)
const spinnerStyle = document.createElement('style')
spinnerStyle.textContent = '@keyframes loading-spin{to{transform:rotate(360deg)}}#loading-progress .loading-spinner{display:inline-block;width:11px;height:11px;margin-right:7px;vertical-align:-1px;border:2px solid rgba(255,255,255,0.3);border-top-color:#fff;border-radius:50%;animation:loading-spin 0.8s linear infinite;will-change:transform}'
document.head.appendChild(spinnerStyle)
let finalRendering = false
let modelsTotal = 0, modelsDone = 0, modelsFailed = 0
let progressPending = false
let progressDetail = ''
let prebuiltDetail = ''
function progressText() {
let pct = modelsTotal ? Math.floor(modelsDone / modelsTotal * 100) : 100
let failed = modelsFailed ? ' (' + modelsFailed + ' failed)' : ''
let parts = [prebuiltDetail, progressDetail].filter(Boolean).join(', ')
let detail = (modelsLoading && parts) ? ' \u2013 ' + parts : ''
return (modelsLoading ? 'Loading models: ' : 'Loaded ') + pct + '% (' + modelsDone + '/' + modelsTotal + ')' + failed + detail
}
function setProgressDetail(text) {
progressDetail = text
updateProgress()
}
function updateProgress() {
// Batch DOM updates to one per animation frame
if (progressPending) return
progressPending = true
requestAnimationFrame(function () { progressPending = false; if (!finalRendering) loadingIndicator.textContent = progressText() })
}
function modelDone(failed) {
modelsDone++
if (failed) modelsFailed++
updateProgress()
}
function loadProgressRender() {
if (!modelsLoading) return requestRender()
if (loadRedraw !== 'interval' || loadRenderTimer) return
let wait = Math.max(0, lastLoadRender + redrawIntervalMs - performance.now())
loadRenderTimer = setTimeout(function () {
loadRenderTimer = null
lastLoadRender = performance.now()
requestRender()
}, wait)
}
// Done once both the loading manager (pre-built models, ground texture) and
// the brick-built models have finished.
let managerDone = false
let ugcDone = lxfml_file_list.length === 0
function partFinished() {
if (managerDone && ugcDone && modelsLoading) loadsFinished()
}
function loadsFinished() {
modelsLoading = false
if (loadRenderTimer) { clearTimeout(loadRenderTimer); loadRenderTimer = null }
// The final full render can block the main thread for a while on large
// properties. Show a 'Rendering' state with a CSS spinner (a transform
// animation, which the compositor keeps running while the main thread is
// busy), let it paint, then render and show the result.
let parts = 0
scene.traverse(function (o) { if (o.isMesh) parts++ })
finalRendering = true
loadingIndicator.textContent = ''
const spinner = document.createElement('span')
spinner.className = 'loading-spinner'
loadingIndicator.appendChild(spinner)
loadingIndicator.appendChild(document.createTextNode(parts ? 'Rendering ' + parts + ' parts\u2026' : 'Rendering\u2026'))
requestAnimationFrame(function () {
requestAnimationFrame(function () {
renderer.render(scene, camera)
finalRendering = false
loadingIndicator.textContent = progressText()
loadingIndicator.style.background = modelsFailed ? 'rgba(170,70,0,0.94)' : 'rgba(0,105,40,0.94)'
// Keep the completion state visible briefly, then fade to a faint badge
setTimeout(function () { loadingIndicator.style.opacity = '0.7' }, 3000)
})
})
}
// Shared by the model/texture loaders; onLoad fires once all queued items
// (successful or failed) have finished.
let loadsStarted = false
const loadingManager = new THREE.LoadingManager(function () { managerDone = true; partFinished() }, loadProgressRender, loadProgressRender)
loadingManager.onStart = function () { loadsStarted = true; managerDone = false; loadingIndicator.style.opacity = '1' }
// Load in pre-built models
let obj_file_list = [
{% for model in content %}
@@ -1408,25 +1100,12 @@
{% endfor %}
]
modelsTotal = obj_file_list.length + lxfml_file_list.length
modelsDone = 0
loadingIndicator.textContent = progressText()
// Pre-built stage on the badge: materials arrive before any OBJ can load
let materialsLoaded = 0
function materialLoaded() {
materialsLoaded++
prebuiltDetail = materialsLoaded < obj_file_list.length ? 'materials ' + materialsLoaded + '/' + obj_file_list.length : ''
updateProgress()
}
if (obj_file_list.length) { prebuiltDetail = 'materials 0/' + obj_file_list.length; updateProgress() }
for (let i = 0; i < obj_file_list.length; i++) {
let mtlLoader = new MTLLoader(loadingManager);
let mtlLoader = new MTLLoader();
mtlLoader.load( obj_file_list[i].mtl, function( materials ) {
materialLoaded()
materials.preload();
let objLoader = new OBJLoader(loadingManager);
let objLoader = new OBJLoader();
objLoader.setMaterials( materials );
objLoader.load( obj_file_list[i].obj, function ( object ) {
@@ -1448,10 +1127,8 @@
scene.add( newModel );
};
modelDone(false)
loadProgressRender()
}, onProgress, function (error) { modelDone(true); onError(error) } );
}, undefined, function (error) { materialLoaded(); modelDone(true); onError(error) });
}, onProgress, onError );
});
}
// Three.JS stuff
@@ -1469,7 +1146,7 @@
{% if center %}
let center = new THREE.Vector3{{ center }};
let groundTexture = new THREE.TextureLoader(loadingManager).load( "{{url_for('luclient.get_dds_as_png', filename='env_nim_ag_grass.dds')}}");
let groundTexture = new THREE.TextureLoader().load( "{{url_for('luclient.get_dds_as_png', filename='env_nim_ag_grass.dds')}}");
groundTexture.wrapS = groundTexture.wrapT = THREE.RepeatWrapping;
groundTexture.repeat.set( 10, 10 );
groundTexture.anisotropy = 16;
@@ -1512,20 +1189,13 @@
controls.update()
// if ( vnh ) vnh.update();
controls.addEventListener('change', requestRender)
let render = function () {
requestAnimationFrame(render);
// camera.rotation.z += 0.01;
renderer.render(scene, camera);
};
window.addEventListener('resize', function () {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
requestRender()
})
// Nothing queued on the loading manager (e.g. only brick-built models)
if (!loadsStarted) managerDone = true
loadUGCModels().then(function () { ugcDone = true; partFinished() })
partFinished()
requestRender();
render();
</script>
</body>