mirror of
https://github.com/DarkflameUniverse/NexusDashboard.git
synced 2026-11-02 00:27:14 +00:00
* Speed up property model viewer loading - Render the viewer on demand (load completion, camera change, resize) instead of every animation frame - Share the UGC brick geometry cache and parsed Materials.xml across all UGC models on the converter - Serve model and ldddb files with strong ETag/Last-Modified validators and conditional requests so repeat visits get 304s - Gzip model and ldddb responses (COMPRESS_MODEL_RESPONSES), optionally storing .gz copies on disk (CACHE_COMPRESSED_MODELS) Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> * Add configurable redraw behaviour while property models load PROPERTY_VIEWER_LOAD_REDRAW ('none' or 'interval') and PROPERTY_VIEWER_REDRAW_INTERVAL_MS control whether the property viewer redraws as individual models finish loading. A final draw always happens once all loads complete or fail, and a loading indicator is shown meanwhile. Also wrap long lines added by this branch for flake8. Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> * Show model loading progress in the property viewer Replace the static 'Loading models...' badge in the upper-left corner with a progress indicator, e.g. 'Loading models: 42% (89/203)', counting loaded and failed models out of the total. When everything is done it shows 'Loaded 100% (203/203)' (with '(N failed)' if any), turns green (orange on failures) and fades to a faint badge after a few seconds. The indicator is a DOM element updated at most once per animation frame, so it does not trigger canvas redraws in either redraw mode. Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> * Make the loading progress badge more opaque and readable Use a ~93% opaque background for all states (dark while loading, darker green when done, orange on failures), bold white text with a subtle text shadow, border and drop shadow, and only fade to 70% opacity after completion so it stays legible over colourful models. Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> * Batch UGC models and load the viewer's brick data asynchronously Brick-built (UGC) models used to be loaded with synchronous XHRs on the main thread: one request per model's LXFML plus one per brick primitive file, strictly one after another, with the page frozen meanwhile. On a remote server every request costs a network round trip, so a property with 654 UGC models took about two minutes to load. - New route properties.get_ugc_models/<property id> (login required, like get_model) returns all UGC LXFML of a property as one JSON object ({content id: lxfml}) from a single DB query. It is gzip-encoded per COMPRESS_MODEL_RESPONSES and has an ETag over the whole set, so repeat loads get a 304. The per-model get_model route keeps working. - The viewer lists the brick database, fetches the LXFML (batched on view_models, per model with bounded concurrency otherwise) and then each needed brick primitive exactly once with at most 8 requests in flight, all with async fetch. Meshes are then built in short slices so the page stays responsive. The progress badge counts brick-built models as they are built and shows the fetch stage (e.g. 'bricks 120/385'). - Loading finishes once both the loading manager (pre-built models, ground texture) and the brick-built models are done; redraw modes are unchanged ('none' draws once at the end, 'interval' at most every N ms). - get_model for a UGC model only decompresses the LXFML when it has to send a body (a matching If-None-Match skips it). Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> * Show the prebuilt material stage on the loading badge While prebuilt models load, the badge now shows how many MTL files have arrived (e.g. 'materials 120/203'), so progress moves from the start on prebuilt-heavy properties instead of sitting at 0% until the first OBJ loads. Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> * Show a rendering state on the badge before the final render The final full render can block the main thread for a second or more on large properties, which made the badge look frozen. Once all loads are done the badge now shows 'Rendering N parts…' with a CSS spinner (a transform animation the compositor keeps running), waits two animation frames so it is painted, then renders and shows the Loaded state. Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com> --------- Co-authored-by: bob-g-code <346622190+bob-g-code@users.noreply.github.com> Co-authored-by: HailStorm32 <demetriv.s.7@gmail.com>
1533 lines
51 KiB
Django/Jinja
1533 lines
51 KiB
Django/Jinja
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<title>LDD Webviewer</title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
|
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='lddviewer/main.css')}}">
|
|
<style>
|
|
body {
|
|
color: #444;
|
|
}
|
|
a {
|
|
color: #08f;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="info">
|
|
LDD (lxfml) Webviewer
|
|
<a href="https://github.com/sttng/LDD-Webviewer">
|
|
Credit to sttng
|
|
</a>
|
|
<br/>
|
|
{% if (property_data and current_user.gm_level >= 3) %}
|
|
<a role="button" class="btn text-{% if property_data.mod_approved %}danger{% else %}success{% endif %} btn-block"
|
|
href='{{url_for('properties.approve', id=property_data.id)}}'>
|
|
{% if property_data.mod_approved %} Unapprove {% else %} Approve {% endif %}
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/three@0.116.0/build/three.min.js"></script>
|
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/three@0.116.0/examples/js/controls/OrbitControls.js"></script>
|
|
|
|
|
|
<script type="text/javascript" src="{{ url_for('static', filename='lddviewer/base64-binary.js') }}"></script>
|
|
|
|
<script type='module'>
|
|
import {MTLLoader} from 'https://cdn.jsdelivr.net/npm/three@0.116.0/examples/jsm/loaders/MTLLoader.js'
|
|
import {OBJLoader} from 'https://cdn.jsdelivr.net/npm/three@0.116.0/examples/jsm/loaders/OBJLoader.js'
|
|
//Three.js stuff
|
|
const scene = new THREE.Scene();
|
|
let cammatr = new THREE.Matrix4();
|
|
{% if center %}
|
|
let brick_pos = new THREE.Vector3{{center}};
|
|
{% else %}
|
|
let brick_pos = new THREE.Vector3();
|
|
{% endif %}
|
|
|
|
|
|
class Matrix3D{
|
|
//done
|
|
constructor(n11=1,n12=0,n13=0,n14=0,n21=0,n22=1,n23=0,n24=0,n31=0,n32=0,n33=1,n34=0,n41=0,n42=0,n43=0,n44=1){
|
|
this.n11 = n11
|
|
this.n12 = n12
|
|
this.n13 = n13
|
|
this.n14 = n14
|
|
this.n21 = n21
|
|
this.n22 = n22
|
|
this.n23 = n23
|
|
this.n24 = n24
|
|
this.n31 = n31
|
|
this.n32 = n32
|
|
this.n33 = n33
|
|
this.n34 = n34
|
|
this.n41 = n41
|
|
this.n42 = n42
|
|
this.n43 = n43
|
|
this.n44 = n44
|
|
}
|
|
|
|
toString(){
|
|
return `[${this.n11}, ${this.n12}, ${this.n13}, ${this.n14}, ${this.n21}, ${this.n22}, ${this.n23}, ${this.n24}, ${this.n31}, ${this.n32}, ${this.n33}, ${this.n34}, ${this.n41}, ${this.n42}, ${this.n43}, ${this.n44}]`
|
|
}
|
|
|
|
rotate(angle, axis){
|
|
let c = Math.cos(angle)
|
|
let s = Math.sin(angle)
|
|
let t = 1 - c
|
|
|
|
let tx = t * axis.x
|
|
let ty = t * axis.y
|
|
let tz = t * axis.z
|
|
|
|
let sx = s * axis.x
|
|
let sy = s * axis.y
|
|
let sz = s * axis.z
|
|
|
|
this.n11 = c + axis.x * tx
|
|
this.n12 = axis.y * tx + sz
|
|
this.n13 = axis.z * tx - sy
|
|
this.n14 = 0
|
|
|
|
this.n21 = axis.x * ty - sz
|
|
this.n22 = c + axis.y * ty
|
|
this.n23 = axis.z * ty + sx
|
|
this.n24 = 0
|
|
|
|
this.n31 = axis.x * tz + sy
|
|
this.n32 = axis.y * tz - sx
|
|
this.n33 = c + axis.z * tz
|
|
this.n34 = 0
|
|
|
|
this.n41 = 0
|
|
this.n42 = 0
|
|
this.n43 = 0
|
|
this.n44 = 1
|
|
}
|
|
|
|
mul(other){
|
|
return new Matrix3D(this.n11 * other.n11 + this.n21 * other.n12 + this.n31 * other.n13 + this.n41 * other.n14,
|
|
this.n12 * other.n11 + this.n22 * other.n12 + this.n32 * other.n13 + this.n42 * other.n14,
|
|
this.n13 * other.n11 + this.n23 * other.n12 + this.n33 * other.n13 + this.n43 * other.n14,
|
|
this.n14 * other.n11 + this.n24 * other.n12 + this.n34 * other.n13 + this.n44 * other.n14,
|
|
this.n11 * other.n21 + this.n21 * other.n22 + this.n31 * other.n23 + this.n41 * other.n24,
|
|
this.n12 * other.n21 + this.n22 * other.n22 + this.n32 * other.n23 + this.n42 * other.n24,
|
|
this.n13 * other.n21 + this.n23 * other.n22 + this.n33 * other.n23 + this.n43 * other.n24,
|
|
this.n14 * other.n21 + this.n24 * other.n22 + this.n34 * other.n23 + this.n44 * other.n24,
|
|
this.n11 * other.n31 + this.n21 * other.n32 + this.n31 * other.n33 + this.n41 * other.n34,
|
|
this.n12 * other.n31 + this.n22 * other.n32 + this.n32 * other.n33 + this.n42 * other.n34,
|
|
this.n13 * other.n31 + this.n23 * other.n32 + this.n33 * other.n33 + this.n43 * other.n34,
|
|
this.n14 * other.n31 + this.n24 * other.n32 + this.n34 * other.n33 + this.n44 * other.n34,
|
|
this.n11 * other.n41 + this.n21 * other.n42 + this.n31 * other.n43 + this.n41 * other.n44,
|
|
this.n12 * other.n41 + this.n22 * other.n42 + this.n32 * other.n43 + this.n42 * other.n44,
|
|
this.n13 * other.n41 + this.n23 * other.n42 + this.n33 * other.n43 + this.n43 * other.n44,
|
|
this.n14 * other.n41 + this.n24 * other.n42 + this.n34 * other.n43 + this.n44 * other.n44)
|
|
}
|
|
}
|
|
|
|
|
|
class Point3D{
|
|
//done
|
|
constructor(x=0,y=0,z=0){
|
|
this.x = x
|
|
this.y = y
|
|
this.z = z
|
|
}
|
|
|
|
toString(){
|
|
return `[${this.x}, ${this.y}, ${this.z}]`
|
|
}
|
|
|
|
transformW(matrix){
|
|
let x = matrix.n11 * this.x + matrix.n21 * this.y + matrix.n31 * this.z
|
|
let y = matrix.n12 * this.x + matrix.n22 * this.y + matrix.n32 * this.z
|
|
let z = matrix.n13 * this.x + matrix.n23 * this.y + matrix.n33 * this.z
|
|
this.x = x
|
|
this.y = y
|
|
this.z = z
|
|
}
|
|
|
|
transform(matrix){
|
|
let x = matrix.n11 * this.x + matrix.n21 * this.y + matrix.n31 * this.z + matrix.n41
|
|
let y = matrix.n12 * this.x + matrix.n22 * this.y + matrix.n32 * this.z + matrix.n42
|
|
let z = matrix.n13 * this.x + matrix.n23 * this.y + matrix.n33 * this.z + matrix.n43
|
|
this.x = x
|
|
this.y = y
|
|
this.z = z
|
|
}
|
|
|
|
copy(){
|
|
return new Point3D(this.x, this.y, this.z)
|
|
}
|
|
}
|
|
|
|
|
|
class Point2D{
|
|
//done
|
|
constructor(x=0,y=0){
|
|
this.x = x
|
|
this.y = y
|
|
}
|
|
|
|
toString(){
|
|
return `[${this.x}, ${this.y}]`
|
|
}
|
|
|
|
copy(){
|
|
return new Point2D(this.x, this.y)
|
|
}
|
|
}
|
|
|
|
|
|
class Face{
|
|
//done
|
|
constructor(a=0,b=0,c=0){
|
|
this.a = a
|
|
this.b = b
|
|
this.c = c
|
|
}
|
|
|
|
toString(){
|
|
return `[${this.a}, ${this.b}, ${this.c}]`
|
|
}
|
|
}
|
|
|
|
|
|
class Group{
|
|
//done
|
|
constructor(node){
|
|
this.partRefs = node.getAttribute('partRefs').split(',')
|
|
}
|
|
}
|
|
|
|
|
|
class Bone{
|
|
//done
|
|
constructor(node){
|
|
this.refID = node.getAttribute('refID')
|
|
//console.log(node.getAttribute('transformation').split(',').map(parseFloat))
|
|
let [a, b, c, d, e, f, g, h, i, x, y, z] = node.getAttribute('transformation').split(',').map(parseFloat);
|
|
this.matrix = new Matrix3D(a,b,c,0,d,e,f,0,g,h,i,0,x,y,z,1);
|
|
//console.log(this.refID)
|
|
//console.log(this.matrix)
|
|
}
|
|
}
|
|
|
|
|
|
class Part{
|
|
//done
|
|
constructor(node){
|
|
this.isGrouped = false
|
|
this.GroupIDX = 0
|
|
this.Bones = []
|
|
this.refID = node.getAttribute('refID')
|
|
this.designID = node.getAttribute('designID')
|
|
this.materials = node.getAttribute('materials').split(',')
|
|
|
|
let lastm = '0'
|
|
for (const [i, m] of this.materials.entries()) {
|
|
if (m == '0'){
|
|
//this.materials[i] = lastm
|
|
this.materials[i] = this.materials[0]
|
|
}
|
|
else {
|
|
lastm = m
|
|
}
|
|
}
|
|
|
|
if (node.hasAttribute('decoration')){
|
|
this.decoration = node.getAttribute('decoration').split(',')
|
|
}
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'Bone'){
|
|
this.Bones.push(new Bone(childnode))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class Brick{
|
|
//done
|
|
constructor(node){
|
|
this.refID = node.getAttribute('refID')
|
|
this.designID = node.getAttribute('designID')
|
|
this.Parts = []
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'Part'){
|
|
this.Parts.push(new Part(childnode))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class Scene{
|
|
constructor(){
|
|
this.Bricks = []
|
|
this.Scenecamera = []
|
|
this.Groups = []
|
|
this.SceneBuildingInstructions = []
|
|
this.xmldata = ''
|
|
}
|
|
|
|
AddScene(file){
|
|
let lxfmlfile = new DBURLFile(file,file)
|
|
this.AddSceneData(lxfmlfile.read())
|
|
}
|
|
|
|
AddSceneData(xmldata){
|
|
this.xmldata = xmldata
|
|
|
|
if (this.xmldata === ""){
|
|
alert(this.xmldata)
|
|
}
|
|
|
|
let parser = new DOMParser();
|
|
let xml = parser.parseFromString(this.xmldata, "text/xml");
|
|
|
|
let nodes = xml.firstChild.childNodes;
|
|
for (let i = 0; i < nodes.length ;i++) {
|
|
let node = nodes[i]
|
|
if (node.nodeName == 'Meta'){
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'BrickSet'){
|
|
this.Version = childnode.getAttribute('version')
|
|
}
|
|
}
|
|
}
|
|
else if (node.nodeName == 'Bricks'){
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'Brick'){
|
|
this.Bricks.push(new Brick(childnode))
|
|
}
|
|
}
|
|
}
|
|
else if (node.nodeName == 'GroupSystems'){
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'GroupSystem'){
|
|
let subchildnodes = childnode.childNodes
|
|
for (let k = 0; k < subchildnodes.length ;k++) {
|
|
let subchildnode = subchildnodes[k]
|
|
if (subchildnode.nodeName == 'Group'){
|
|
this.Groups.push(new Group(subchildnode))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const [i, m] of this.Groups.entries()) {
|
|
for (const brick of this.Bricks){
|
|
for (const part of brick.Parts){
|
|
if (m.partRefs.indexOf(part.refID) !== -1) {
|
|
part.isGrouped = true
|
|
part.GroupIDX = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
console.log('Scene Loaded Brickversion: ' + this.Version)
|
|
}
|
|
}
|
|
|
|
|
|
class GeometryReader{
|
|
//done
|
|
constructor(data){
|
|
this.offset = 0
|
|
this.data = data
|
|
this.positions = []
|
|
this.normals = []
|
|
this.textures = []
|
|
this.faces = []
|
|
this.bonemap = []
|
|
this.texCount = 0
|
|
this.outpositions = []
|
|
this.outnormals = []
|
|
|
|
this.binary = "";
|
|
|
|
for (let i = 0; i < data.length; i++ ) {
|
|
this.binary += String.fromCharCode(data.charCodeAt(i) & 255)
|
|
}
|
|
this.data = Base64Binary.decodeArrayBuffer(btoa(this.binary));
|
|
this.view = new Uint8Array(this.data);
|
|
|
|
if (this.readInt() == 1111961649){
|
|
this.valueCount = this.readInt()
|
|
this.indexCount = this.readInt()
|
|
this.faceCount = this.indexCount / 3
|
|
let options = this.readInt()
|
|
|
|
for (let i = 0; i < this.valueCount ;i++) {
|
|
this.positions.push(new Point3D(this.readFloat(), this.readFloat(), this.readFloat()))
|
|
}
|
|
for (let i = 0; i < this.valueCount ;i++) {
|
|
this.normals.push(new Point3D(this.readFloat(), this.readFloat(), this.readFloat()))
|
|
}
|
|
if ((options & 3) == 3){
|
|
this.texCount = this.valueCount
|
|
for (let i = 0; i < this.valueCount ;i++) {
|
|
this.textures.push(new Point2D(this.readFloat(), this.readFloat()))
|
|
}
|
|
}
|
|
for (let i = 0; i < this.faceCount ;i++) {
|
|
this.faces.push(new Face(this.readInt(), this.readInt(), this.readInt()))
|
|
}
|
|
if ((options & 48) == 48){
|
|
let num = this.readInt()
|
|
this.offset += (num * 4) + (this.indexCount * 4)
|
|
num = this.readInt()
|
|
this.offset += (3 * num * 4) + (this.indexCount * 4)
|
|
}
|
|
|
|
let bonelength = this.readInt()
|
|
this.bonemap.length = this.valueCount
|
|
this.bonemap.fill(0);
|
|
|
|
if ((bonelength > this.valueCount) || (bonelength > this.faceCount)){
|
|
let datastart = this.offset
|
|
this.offset += bonelength
|
|
for (let i = 0; i < this.valueCount ;i++) {
|
|
let boneoffset = this.readInt() + 4
|
|
this.bonemap[i] = this.read_Int(datastart + boneoffset)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
read_Int(_offset){
|
|
//console.log(_offset)
|
|
let ret = (this.view[_offset+0]) + (this.view[_offset+1]<<8)+ (this.view[_offset+2]<<16) + (this.view[_offset+3]<<24)
|
|
//console.log(ret)
|
|
return ret
|
|
}
|
|
|
|
readInt(){
|
|
//let ret = (this.view[this.offset+0]<<24) + (this.view[this.offset+1]<<16)+ (this.view[this.offset+2]<<8) + (this.view[this.offset+3])
|
|
let ret = (this.view[this.offset+0]) + (this.view[this.offset+1]<<8)+ (this.view[this.offset+2]<<16) + (this.view[this.offset+3]<<24)
|
|
this.offset += 4
|
|
//console.log(ret)
|
|
return ret
|
|
}
|
|
|
|
readFloat(){
|
|
let tempdata = [(this.view[this.offset+3]), (this.view[this.offset+2]), (this.view[this.offset+1]), (this.view[this.offset+0])];
|
|
// Create a buffer and a data view of it
|
|
let buf = new ArrayBuffer(4);
|
|
let view = new DataView(buf);
|
|
|
|
// set bytes
|
|
tempdata.forEach(function (b, i) {
|
|
view.setInt8(i, b);
|
|
});
|
|
|
|
// Read the bits as a float; note that by doing this, we're implicitly converting it from a 32-bit float into JavaScript's native 64-bit double
|
|
let number = view.getFloat32(0);
|
|
this.offset += 4
|
|
return number
|
|
}
|
|
}
|
|
|
|
|
|
class Geometry {
|
|
//done
|
|
constructor(designID, database){
|
|
this.designID = designID
|
|
this.Parts = []
|
|
this.studsFields2D = []
|
|
let lod = {{ lod }}
|
|
let GeometryLocation = `brickprimitives/lod${lod}/${designID}.g`
|
|
let PrimitiveLocation = `Primitives/${designID}.xml`
|
|
|
|
let GeometryCount = 0
|
|
while (GeometryLocation in database.filelist) {
|
|
this.Parts[GeometryCount] = new GeometryReader(database.filelist[GeometryLocation].read())
|
|
GeometryCount = GeometryCount + 1
|
|
GeometryLocation = `brickprimitives/lod${lod}/${designID}.g${GeometryCount}`
|
|
}
|
|
let primitive = new Primitive(database.filelist[PrimitiveLocation].read())
|
|
this.Partname = primitive.Designname
|
|
this.studsFields2D = primitive.Fields2D
|
|
|
|
//preflex
|
|
for (const [h, part] of this.Parts.entries()) {
|
|
//transform
|
|
for (const [i, b] of primitive.Bones.entries()) {
|
|
//positions
|
|
for (const [j, p] of this.Parts[h].positions.entries()) {
|
|
if (this.Parts[h].bonemap[j] == i){
|
|
this.Parts[h].positions[j].transform(b.matrix)
|
|
}
|
|
}
|
|
//normals
|
|
for (const [k, n] of this.Parts[h].normals.entries()) {
|
|
if (this.Parts[h].bonemap[k] == i){
|
|
this.Parts[h].normals[k].transform(b.matrix)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
valuecount(){
|
|
let count = 0
|
|
for (const part of this.Parts) {
|
|
count += part.valueCount
|
|
}
|
|
return count
|
|
}
|
|
|
|
facecount(){
|
|
let count = 0
|
|
for (const part of this.Parts) {
|
|
count += part.facecount
|
|
}
|
|
return count
|
|
}
|
|
|
|
texcount(){
|
|
let count = 0
|
|
for (const part of this.Parts) {
|
|
count += part.texCount
|
|
}
|
|
return count
|
|
}
|
|
}
|
|
|
|
|
|
class DBinfo {
|
|
//done
|
|
constructor(data) {
|
|
let parser = new DOMParser();
|
|
let xml = parser.parseFromString(data, "text/xml");
|
|
let Version = xml.getElementsByTagName('Bricks')[0].attributes['version'].value
|
|
console.log('DB Version: ' + Version);
|
|
return ('DB Version: ' + Version);
|
|
}
|
|
}
|
|
|
|
|
|
class Converter {
|
|
|
|
constructor(){
|
|
this.allMaterials = ''
|
|
// brick geometry is shared by every UGC model converted by this converter
|
|
this.geometryCache = {}
|
|
this.scene = new Scene()
|
|
}
|
|
|
|
LoadDBURL(dbURLlocation){
|
|
this.database = new DBURLReader(dbURLlocation)
|
|
if(this.database.initok && this.database.fileexist('Materials.xml')){
|
|
this.allMaterials = new Materials(this.database.filelist['Materials.xml'].read())
|
|
}
|
|
}
|
|
|
|
LoadScene(filename){
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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 total = this.scene.Bricks.length
|
|
let current = 0
|
|
|
|
for (const cam of this.scene.Scenecamera){
|
|
let camm = new THREE.Matrix4();
|
|
|
|
camm.set(
|
|
cam.matrix.n11, cam.matrix.n21, cam.matrix.n31, cam.matrix.n41,
|
|
cam.matrix.n12, cam.matrix.n22, cam.matrix.n32, cam.matrix.n42,
|
|
cam.matrix.n13, cam.matrix.n23, cam.matrix.n33, cam.matrix.n43,
|
|
cam.matrix.n14, cam.matrix.n24+30.0, cam.matrix.n34+24.0, cam.matrix.n44
|
|
);
|
|
|
|
cammatr.getInverse(camm)
|
|
}
|
|
|
|
for (const bri of this.scene.Bricks){
|
|
current += 1
|
|
for (const pa of bri.Parts){
|
|
let geo = 0
|
|
if (geometriecache.hasOwnProperty(pa.designID)) {
|
|
// console.log(`Re-use brick ${pa.designID}`)
|
|
geo = geometriecache[pa.designID]
|
|
}
|
|
else {
|
|
// console.log(`New brick ${pa.designID}`)
|
|
geo = new Geometry(pa.designID, this.database)
|
|
geometriecache[pa.designID] = geo
|
|
}
|
|
|
|
let ind = 0
|
|
let n11 = pa.Bones[ind].matrix.n11
|
|
let n12 = pa.Bones[ind].matrix.n12
|
|
let n13 = pa.Bones[ind].matrix.n13
|
|
let n14 = pa.Bones[ind].matrix.n14
|
|
let n21 = pa.Bones[ind].matrix.n21
|
|
let n22 = pa.Bones[ind].matrix.n22
|
|
let n23 = pa.Bones[ind].matrix.n23
|
|
let n24 = pa.Bones[ind].matrix.n24
|
|
let n31 = pa.Bones[ind].matrix.n31
|
|
let n32 = pa.Bones[ind].matrix.n32
|
|
let n33 = pa.Bones[ind].matrix.n33
|
|
let n34 = pa.Bones[ind].matrix.n34
|
|
let n41 = pa.Bones[ind].matrix.n41
|
|
let n42 = pa.Bones[ind].matrix.n42
|
|
let n43 = pa.Bones[ind].matrix.n43
|
|
let n44 = pa.Bones[ind].matrix.n44
|
|
|
|
let m = new THREE.Matrix4();
|
|
|
|
// Only parts with more then 1 bone are flex parts
|
|
let flexflag = 1
|
|
|
|
|
|
if (!(pa.Bones.length > flexflag)){
|
|
m.set( n11, n21, n31, n41,
|
|
n12, n22, n32, n42,
|
|
n13, n23, n33, n43,
|
|
n14, n24 ,n34, n44);
|
|
}
|
|
|
|
let decoCount = 0
|
|
|
|
for (const [partindex, part] of geo.Parts.entries()){
|
|
part.outpositions = Array.from(part.positions);
|
|
part.outnormals = Array.from(part.normals);
|
|
|
|
// translate / rotate only parts with more then 1 bone. This are flex parts.
|
|
if (pa.Bones.length > flexflag){
|
|
for (const [i, b] of pa.Bones.entries()) {
|
|
//positions
|
|
for (const [j, p] of part.outpositions.entries()){
|
|
if (part.bonemap[j] == i){
|
|
p.transform(invert.mul(b.matrix))
|
|
}
|
|
}
|
|
//normals
|
|
for (const [k, n] of part.outnormals.entries()){
|
|
if (part.bonemap[k] == i){
|
|
n.transformW(invert.mul(b.matrix))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let parr = []
|
|
for (const point of part.outpositions){
|
|
parr.push(point.x)
|
|
parr.push(point.y)
|
|
parr.push(point.z)
|
|
}
|
|
let threevertices = new Float32Array(parr)
|
|
|
|
let narr = []
|
|
for (const normal of part.outnormals){
|
|
narr.push(normal.x)
|
|
narr.push(normal.y)
|
|
narr.push(normal.z)
|
|
}
|
|
let threenormals = new Float32Array(narr)
|
|
|
|
let tarr = []
|
|
for (const text of part.textures){
|
|
tarr.push(text.x)
|
|
// NOTE Three.js maps Textures in from top to bottom so we calculate 1.0 - t so the image will map properly
|
|
tarr.push(1 - text.y)
|
|
//console.log(text.toString())
|
|
}
|
|
let threeuvs = new Float32Array(tarr)
|
|
|
|
let farr =[]
|
|
for (const face of part.faces){
|
|
farr.push(face.a)
|
|
farr.push(face.b)
|
|
farr.push(face.c)
|
|
//console.log(face.toString())
|
|
}
|
|
|
|
let materialCurrentPart = pa.materials[partindex]
|
|
let lddmat = allMaterials.getMaterialbyId(materialCurrentPart)
|
|
|
|
if (typeof lddmat !== 'undefined'){
|
|
//nothing. Everything ok.
|
|
}
|
|
else {
|
|
//lddmat undefined
|
|
console.log('partindex: ' + partindex)
|
|
console.log(pa.materials)
|
|
|
|
lddmat = allMaterials.getMaterialbyId(21)
|
|
}
|
|
|
|
let deco = '0'
|
|
if (pa.hasOwnProperty('decoration') && geo.Parts[partindex].textures.length > 0 ){
|
|
if (decoCount < pa.decoration.length){
|
|
deco = pa.decoration[decoCount]
|
|
}
|
|
decoCount += 1
|
|
}
|
|
|
|
let material = new THREE.MeshPhongMaterial();
|
|
material.color.set(lddmat.toString());
|
|
material.transparent = true
|
|
material.opacity = (lddmat.a / 255)
|
|
let material1 = new THREE.MeshPhongMaterial();
|
|
let materials = [];
|
|
materials.push(material)
|
|
|
|
let geometry = new THREE.BufferGeometry();
|
|
geometry.addGroup( 0, Infinity, 0 );
|
|
|
|
if (!(deco == '0')){
|
|
let DECORATIONPATH = 'Decorations/'
|
|
let decofilename = DECORATIONPATH + deco + '.png'
|
|
let extfile = this.database.filelist[decofilename].urlHandle
|
|
let texture = new THREE.TextureLoader().load( extfile );
|
|
material1 = new THREE.MeshPhongMaterial( { map: texture } );
|
|
material1.transparent = true
|
|
materials.push(material1)
|
|
geometry.addGroup( 0, Infinity, 1 );
|
|
}
|
|
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(threevertices, 3));
|
|
geometry.setAttribute('normal', new THREE.BufferAttribute(threenormals, 3));
|
|
geometry.setAttribute('uv', new THREE.BufferAttribute(threeuvs, 2));
|
|
geometry.setIndex(farr);
|
|
|
|
|
|
let mesh = new THREE.Mesh(geometry, materials);
|
|
mesh.matrixAutoUpdate = false
|
|
mesh.matrix = m
|
|
modelGroup.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 %}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class LOCReader {
|
|
//done
|
|
constructor(data) {
|
|
this.offset = 0
|
|
this.values = {}
|
|
this.data = data
|
|
if (this.data[0].charCodeAt() == 50 && this.data[1].charCodeAt() == 0){
|
|
|
|
this.offset += 2
|
|
while (this.offset < this.data.length){
|
|
let key = this.NextString().replace('Material', '')
|
|
let value = this.NextString()
|
|
this.values[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
NextString(){
|
|
let out =''
|
|
let t = this.data[this.offset].charCodeAt()
|
|
this.offset += 1
|
|
while (t != 0){
|
|
out = out + String.fromCharCode(t)
|
|
t = this.data[this.offset].charCodeAt()
|
|
this.offset += 1
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
|
|
class Flex {
|
|
//done
|
|
constructor(boneId=0, angle=0, ax=0, ay=0, az=0, tx=0, ty=0, tz=0){
|
|
this.boneId = boneId
|
|
let rotationMatrix = new Matrix3D()
|
|
rotationMatrix.rotate(-angle * Math.PI / 180.0, new Point3D(ax, ay, az))
|
|
let p = new Point3D(tx, ty, tz)
|
|
p.transformW(rotationMatrix)
|
|
rotationMatrix.n41 -= p.x
|
|
rotationMatrix.n42 -= p.y
|
|
rotationMatrix.n43 -= p.z
|
|
this.matrix = rotationMatrix
|
|
}
|
|
}
|
|
|
|
|
|
class Field2D{
|
|
//done
|
|
constructor(type=0, width=0, height=0, angle=0, ax=0, ay=0, az=0, tx=0, ty=0, tz=0, field2DRawData='none'){
|
|
this.type = type
|
|
this.field2DRawData = field2DRawData
|
|
let rotationMatrix = new Matrix3D()
|
|
rotationMatrix.rotate(-angle * Math.PI / 180.0, new Point3D(ax, ay, az))
|
|
let p = new Point3D(tx, ty, tz)
|
|
p.transformW(rotationMatrix)
|
|
rotationMatrix.n41 -= p.x
|
|
rotationMatrix.n42 -= p.y
|
|
rotationMatrix.n43 -= p.z
|
|
this.matrix = rotationMatrix
|
|
this.custom2DField = []
|
|
|
|
let rows_count = height + 1
|
|
let cols_count = width + 1
|
|
this.custom2DField = new Array(rows_count).fill(new Array(cols_count).fill(0));
|
|
let custom2DFieldString = field2DRawData.replace(/[\r\n\x0B\x0C\u0085\u2028\u2029]+/g, '').replace(/\s/g, '')
|
|
let custom2DFieldArr = custom2DFieldString.split(',')
|
|
|
|
let k = 0
|
|
for (let i = 0; i < rows_count ;i++) {
|
|
for (let j = 0; j < cols_count ;j++){
|
|
this.custom2DField[i][j] = custom2DFieldArr[k]
|
|
k += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class Primitive {
|
|
//done
|
|
constructor(data){
|
|
this.Designname = ''
|
|
this.Bones = []
|
|
this.Fields2D = []
|
|
this.PhysicsAttributes = {}
|
|
this.Bounding = {}
|
|
this.GeometryBounding = {}
|
|
let parser = new DOMParser();
|
|
let xml = parser.parseFromString(data,"text/xml");
|
|
|
|
let nodes = xml.firstChild.childNodes;
|
|
for (let i = 0; i < nodes.length ;i++) {
|
|
let node = nodes[i]
|
|
if (node.nodeName == 'Flex'){
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'Bone'){
|
|
this.Bones.push(new Flex(parseInt(childnode.getAttribute('boneId')), parseFloat(childnode.getAttribute('angle')), parseFloat(childnode.getAttribute('ax')), parseFloat(childnode.getAttribute('ay')), parseFloat(childnode.getAttribute('az')), parseFloat(childnode.getAttribute('tx')), parseFloat(childnode.getAttribute('ty')), parseFloat(childnode.getAttribute('tz'))))
|
|
}
|
|
}
|
|
}
|
|
else if (node.nodeName == 'Annotations'){
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'Annotation' && childnode.hasAttribute('designname')){
|
|
this.Designname = childnode.getAttribute('designname')
|
|
}
|
|
}
|
|
|
|
}
|
|
else if (node.nodeName == 'Connectivity'){
|
|
let childnodes = node.childNodes
|
|
for (let j = 0; j < childnodes.length ;j++) {
|
|
let childnode = childnodes[j]
|
|
if (childnode.nodeName == 'Custom2DField'){
|
|
this.Fields2D.push(new Field2D(parseInt(childnode.getAttribute('type')), parseInt(childnode.getAttribute('width')), parseInt(childnode.getAttribute('height')), parseFloat(childnode.getAttribute('angle')), parseFloat(childnode.getAttribute('ax')), parseFloat(childnode.getAttribute('ay')), parseFloat(childnode.getAttribute('az')), parseFloat(childnode.getAttribute('tx')), parseFloat(childnode.getAttribute('ty')), parseFloat(childnode.getAttribute('tz')), (childnode.firstChild.data)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class Materials {
|
|
constructor(data) {
|
|
this.Materials = {}
|
|
let parser = new DOMParser();
|
|
let xml = parser.parseFromString(data,"text/xml");
|
|
|
|
let nodes = xml.firstChild.childNodes;
|
|
for (let i = 0; i < nodes.length ;i++) {
|
|
let node = nodes[i]
|
|
if (node.nodeName == 'Material') {
|
|
this.Materials[node.getAttribute('MatID')] = new Material(node.getAttribute('MatID'), parseInt(node.getAttribute('Red')), parseInt(node.getAttribute('Green')), parseInt(node.getAttribute('Blue')), parseInt(node.getAttribute('Alpha')), node.getAttribute('MaterialType'))
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
getMaterialbyId(mid) {
|
|
return this.Materials[mid]
|
|
}
|
|
}
|
|
|
|
|
|
class Material {
|
|
//done
|
|
constructor(id, r, g, b, a, mtype) {
|
|
this.id = id
|
|
this.name = id
|
|
this.mattype = mtype
|
|
this.r = parseFloat(r)
|
|
this.g = parseFloat(g)
|
|
this.b = parseFloat(b)
|
|
this.a = parseFloat(a)
|
|
}
|
|
|
|
string(){
|
|
let out = ('Red: ' + this.r + ' Green: ' + this.g + ' Blue: '+ this.b + ' Aplha: ' + this.a)
|
|
return out;
|
|
}
|
|
|
|
toString(){
|
|
let out = `rgb(${this.r}, ${this.g}, ${this.b})`
|
|
return out;
|
|
}
|
|
}
|
|
|
|
|
|
function FindDBURL(){
|
|
let dburl = '/luclient/ldddb/'
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.open('GET', dburl, false); // `false` makes the request synchronous
|
|
// request state change event
|
|
xhr.onreadystatechange = function() {
|
|
// request completed?
|
|
if (xhr.readyState !== 4) {//return;
|
|
dburl = false;
|
|
console.log('readyState error in FindDBURL:', xhr.status, xhr.statusText);
|
|
}
|
|
if (xhr.status === 200) {
|
|
// request successful - show response
|
|
//console.log(xhr.responseText);
|
|
}
|
|
else {
|
|
// request error
|
|
dburl = false;
|
|
console.log('HTTP error in FindDBURL:', xhr.status, xhr.statusText);
|
|
}
|
|
};
|
|
// start request
|
|
xhr.send();
|
|
return dburl
|
|
}
|
|
|
|
|
|
class DBURLFile {
|
|
constructor(urlHandle, name) {
|
|
this.urlHandle = urlHandle
|
|
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();
|
|
xhr.open('GET', self.urlHandle, false);
|
|
// Hack to pass bytes through unprocessed.
|
|
xhr.overrideMimeType('text/plain; charset=x-user-defined');
|
|
|
|
// request state change event
|
|
xhr.onreadystatechange = function() {
|
|
|
|
// request completed?
|
|
if (xhr.readyState !== 4) {//return;
|
|
console.log('readyState error in DBURLFile:', xhr.status, xhr.statusText);
|
|
}
|
|
if (xhr.status === 200) {
|
|
// request successful - show response
|
|
fileContent = xhr.responseText;
|
|
}
|
|
else {
|
|
// request error
|
|
console.log('HTTP error in DBURLFile:', xhr.status, xhr.statusText);
|
|
}
|
|
};
|
|
|
|
// start request
|
|
xhr.send();
|
|
return fileContent
|
|
}
|
|
}
|
|
|
|
|
|
// 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 = {};
|
|
this.initok = false;
|
|
this.location = dburl;
|
|
this.dbinfo = '';
|
|
this.parse(this.location);
|
|
|
|
// console.log(JSON.stringify(this.filelist))
|
|
|
|
if(this.fileexist('Materials.xml') && this.fileexist('info.xml')){
|
|
this.dbinfo = new DBinfo(this.filelist['info.xml'].read());
|
|
this.initok = true
|
|
}
|
|
else{
|
|
alert("db url ERROR")
|
|
}
|
|
}
|
|
|
|
fileexist(filename) {
|
|
let self = this;
|
|
return self.filelist[filename];
|
|
}
|
|
|
|
parse(dburl, folder="") {
|
|
let self = this;
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.open('GET', dburl, false);
|
|
|
|
// request state change event
|
|
xhr.onreadystatechange = function() {
|
|
|
|
// request completed?
|
|
if (xhr.readyState !== 4) return;
|
|
|
|
if (xhr.status === 200) {
|
|
// request successful - show response
|
|
let data = JSON.parse(xhr.responseText)
|
|
//console.log(JSON.stringify(data, null, "\t"));
|
|
for(let i = 0; i < data.length; i++) {
|
|
let obj = data[i];
|
|
if (obj.type == 'directory'){
|
|
// parse subdirs
|
|
self.parse(dburl + obj.name + '/', obj.name)
|
|
}
|
|
else if (obj.type == 'file'){
|
|
self.filelist[obj.name] = new DBURLFile(dburl + obj.name, obj.name)
|
|
}
|
|
else {
|
|
console.log('Strange object parsed: ' + obj.type)
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// request error
|
|
console.log('HTTP error', xhr.status, xhr.statusText);
|
|
}
|
|
};
|
|
// start request
|
|
xhr.send();
|
|
}
|
|
}
|
|
|
|
|
|
//Start
|
|
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 }}
|
|
},
|
|
{% endif %}
|
|
{% endfor %}
|
|
]
|
|
// All UGC LXFML of a property in one request (view_models); otherwise per model
|
|
const ugcBatchUrl = {{ ugc_batch_url|default(none)|tojson }}
|
|
|
|
// 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')
|
|
}
|
|
const converter = new Converter()
|
|
converter.database = database
|
|
converter.allMaterials = new Materials(database.filelist['Materials.xml'].read())
|
|
|
|
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 %}
|
|
}
|
|
|
|
const onProgress = function ( xhr ) {
|
|
|
|
if ( xhr.lengthComputable ) {
|
|
|
|
const percentComplete = xhr.loaded / xhr.total * 100;
|
|
console.log( Math.round( percentComplete, 2 ) + '% downloaded' );
|
|
|
|
}
|
|
|
|
};
|
|
|
|
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 %}
|
|
{% if model.lot != 14 %}
|
|
{{ model }} {{ ", " if not loop.last else "" }}
|
|
{% endif %}
|
|
{% 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);
|
|
mtlLoader.load( obj_file_list[i].mtl, function( materials ) {
|
|
materialLoaded()
|
|
materials.preload();
|
|
let objLoader = new OBJLoader(loadingManager);
|
|
objLoader.setMaterials( materials );
|
|
|
|
objLoader.load( obj_file_list[i].obj, function ( object ) {
|
|
// console.log(obj_file_list[i].pos)
|
|
for (let j = 0; j < obj_file_list[i].pos.length; j++) {
|
|
// console.log(obj_file_list[i].pos[j])
|
|
var newModel = object.clone();
|
|
newModel.position.x = obj_file_list[i].pos[j].x;
|
|
newModel.position.y = obj_file_list[i].pos[j].y;
|
|
newModel.position.z = obj_file_list[i].pos[j].z;
|
|
|
|
let quaternion = new THREE.Quaternion(
|
|
obj_file_list[i].pos[j].rx,
|
|
obj_file_list[i].pos[j].ry,
|
|
obj_file_list[i].pos[j].rz,
|
|
obj_file_list[i].pos[j].rw
|
|
);
|
|
newModel.rotation.setFromQuaternion(quaternion)
|
|
|
|
scene.add( newModel );
|
|
};
|
|
modelDone(false)
|
|
loadProgressRender()
|
|
}, onProgress, function (error) { modelDone(true); onError(error) } );
|
|
}, undefined, function (error) { materialLoaded(); modelDone(true); onError(error) });
|
|
}
|
|
|
|
// Three.JS stuff
|
|
let container = document.createElement( 'div' );
|
|
document.body.appendChild( container );
|
|
|
|
let camera = new THREE.PerspectiveCamera( 2.5, window.innerWidth / window.innerHeight, 1, 10000 );
|
|
{% if center %}
|
|
camera.position.set( brick_pos.x + 800, brick_pos.y + 800, brick_pos.z + 800 );
|
|
{% elif content|length > 1 %}
|
|
camera.position.set( {{ content[0].x }}+ 200, {{ content[0].y }}+ 300, {{ content[0].z }}+ 200 );
|
|
{% else %}
|
|
camera.position.set( brick_pos.x + 200, brick_pos.y + 300, brick_pos.z + 200 );
|
|
{% endif %}
|
|
|
|
{% 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')}}");
|
|
groundTexture.wrapS = groundTexture.wrapT = THREE.RepeatWrapping;
|
|
groundTexture.repeat.set( 10, 10 );
|
|
groundTexture.anisotropy = 16;
|
|
groundTexture.encoding = THREE.sRGBEncoding;
|
|
let groundMaterial = new THREE.MeshStandardMaterial( { map: groundTexture } );
|
|
let mesh = new THREE.Mesh( new THREE.PlaneBufferGeometry( 400, 400 ), groundMaterial );
|
|
mesh.position.y = center.y;
|
|
mesh.position.x = center.x;
|
|
mesh.position.z = center.z
|
|
mesh.rotation.x = - Math.PI / 2;
|
|
mesh.receiveShadow = true;
|
|
scene.add( mesh );
|
|
{% endif %}
|
|
|
|
// scene
|
|
scene.background = new THREE.Color( 0xdeebed );
|
|
|
|
let ambientLight = new THREE.AmbientLight( 0xdeebed, 0.4 );
|
|
scene.add( ambientLight );
|
|
|
|
let directionalLight = new THREE.DirectionalLight( 0xffffff, 0.75 );
|
|
directionalLight.position.set( - 10000, 12000, 15000 );
|
|
scene.add( directionalLight );
|
|
|
|
let directionalLight2 = new THREE.DirectionalLight( 0xffffff, 0.75 );
|
|
directionalLight2.position.set( 10000, 12000, -15000 );
|
|
scene.add( directionalLight2 );
|
|
|
|
let renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
|
|
renderer.setPixelRatio( window.devicePixelRatio );
|
|
renderer.setSize( window.innerWidth, window.innerHeight );
|
|
container.appendChild( renderer.domElement );
|
|
|
|
let controls = new THREE.OrbitControls (camera, renderer.domElement);
|
|
{% if content|length == 1 and content[0].lot != 14 %}
|
|
controls.target = new THREE.Vector3({{ content[0].pos[0].x }}, {{ content[0].pos[0].y }}, {{ content[0].pos[0].z }})
|
|
{% else %}
|
|
controls.target = brick_pos
|
|
{% endif %}
|
|
controls.update()
|
|
// if ( vnh ) vnh.update();
|
|
|
|
controls.addEventListener('change', requestRender)
|
|
|
|
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();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|