instanced_InstancedTile.js

import * as THREE from 'three';
import { OBB } from "../../geometry/obb";
import { v4 as uuidv4 } from "uuid";
import * as path from "path-browserify";

const tempSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), 1);
const tempVec1 = new THREE.Vector3(0, 0, 0);
const tempVec2 = new THREE.Vector3(0, 0, 0);
const upVector = new THREE.Vector3(0, 1, 0);
const rendererSize = new THREE.Vector2();
const tempQuaternion = new THREE.Quaternion();
const tempMatrix = new THREE.Matrix4();

class InstancedTile extends THREE.Object3D {

    /**
     * 
     * @param {
     *   json: optional,
     *   url: optional,
     *   rootPath: optional,
     *   parentGeometricError: optional,
     *   parentBoundingVolume: optional,
     *   parentRefinement: optional,
     *   loadOutsideView: Boolean,
     *   tileLoader : InstancedTileLoader,
     *   cameraOnLoad: camera,
     *   parentTile: OGC3DTile,
     *   onLoadCallback: function,
     *   centerModel: Boolean,
     *   queryParams: String,
     *   distanceBias: distanceBias
     * } properties 
     */
    constructor(properties) {
        super();
        const self = this;
        if (properties.queryParams) {
            this.queryParams = { ...properties.queryParams };
        }
        this.uuid = uuidv4();
        if (!!properties.tileLoader) {
            this.tileLoader = properties.tileLoader;
        } else {
            console.error("an instanced tileset must be provided an InstancedTilesetLoader");
        }
        // set properties general to the entire tileset
        this.master = properties.master;
        this.loadOutsideView = properties.loadOutsideView;
        this.cameraOnLoad = properties.cameraOnLoad;
        this.parentTile = properties.parentTile;
        this.distanceBias = Math.max(0.0001,properties.distanceBias? properties.distanceBias:1);

        // declare properties specific to the tile for clarity
        this.childrenTiles = [];
        this.jsonChildren = [];
        this.meshContent = new Set();

        this.static = properties.static;
        if (this.static) {
            this.matrixAutoUpdate = false;
            this.matrixWorldAutoUpdate = false;
        }
        this.tileContent;
        this.refinement; // defaults to "REPLACE"
        this.rootPath;
        this.geometricError;
        this.boundingVolume;
        this.json; // the json corresponding to this tile
        this.materialVisibility = false;
        this.inFrustum = true;
        this.level = properties.level ? properties.level : 0;
        this.hasMeshContent = 0; // true when the provided json has a content field pointing to a B3DM file
        this.hasUnloadedJSONContent = 0; // true when the provided json has a content field pointing to a JSON file that is not yet loaded
        this.centerModel = properties.centerModel;

        this.deleted = false;
        this.abortController = new AbortController();

        if (!!properties.json) { // If this tile is created as a child of another tile, properties.json is not null
            this.rootPath = !!properties.json.rootPath ? properties.json.rootPath : properties.rootPath;
            if (properties.json.children) this.jsonChildren = properties.json.children;
            self.setup(properties);
        } else if (properties.url) { // If only the url to the tileset.json is provided


            this.loadJson = (json, url) => {
                //json = JSON.parse(JSON.stringify(json))
                const p = path.dirname(url);
                self.setup({ rootPath: p, json: json, onLoadCallback: properties.onLoadCallback });



            }
            var url = properties.url;
            if (self.queryParams) {
                var props = "";
                for (let key in self.queryParams) {
                    if (self.queryParams.hasOwnProperty(key)) { // This check is necessary to skip properties from the object's prototype chain
                        props += "&" + key + "=" + self.queryParams[key];
                    }
                }
                if (url.includes("?")) {
                    url += props;
                } else {
                    url += "?" + props.substring(1);
                }
            }
            self.tileLoader.get(self.abortController, url, self.uuid, self);
        }
    }

    async setup(properties) {
        const self = this;

        if (!!properties.json.root) {
            self.json = properties.json.root;
            if (!self.json.children && self.json.getChildren) {
                self.json.children = await self.json.getChildren();
            }
            self.jsonChildren = self.json.children;
            if (!self.json.refinement) self.json.refinement = properties.json.refinement;
            if (!self.json.geometricError) self.json.geometricError = properties.json.geometricError;
            if (!self.json.transform) self.json.transform = properties.json.transform;
            if (!self.json.boundingVolume) self.json.boundingVolume = properties.json.boundingVolume;
        } else {
            self.json = properties.json;
            if (!self.json.children && self.json.getChildren) {
                self.json.children = await self.json.getChildren();
                self.jsonChildren = self.json.children;
            }
        }

        self.rootPath = !!properties.json.rootPath ? properties.json.rootPath : properties.rootPath;

        // decode refinement
        if (!!self.json.refinement) {
            self.refinement = self.json.refinement;
        } else {
            self.refinement = properties.parentRefinement;
        }
        // decode geometric error
        if (!!self.json.geometricError) {
            self.geometricError = self.json.geometricError;
        } else {
            self.geometricError = properties.parentGeometricError;
        }
        // decode transform
        let mat = new THREE.Matrix4();
        if (!!self.json.transform && !self.centerModel) {
            mat.elements = self.json.transform;
        }

        self.applyMatrix4(mat);

        if (!!self.parentTile && !!self.parentTile.matrix) {

            self.matrix.premultiply(self.parentTile.matrix);
            //self.matrix.premultiply(self.master.matrixWorld);
            self.matrix.decompose(self.position, self.quaternion, self.scale)
        }


        self.matrixWorldNeedsUpdate = true;
        self.updateWorldMatrix(true, true)
        

        // decode volume
        if (!!self.json.boundingVolume) {
            if (!!self.json.boundingVolume.box) {
                self.boundingVolume = new OBB(self.json.boundingVolume.box);
            } else if (!!self.json.boundingVolume.region) {
                const region = self.json.boundingVolume.region;
                self.transformWGS84ToCartesian(region[0], region[1], region[4], tempVec1);
                self.transformWGS84ToCartesian(region[2], region[3], region[5], tempVec2);
                tempVec1.lerp(tempVec2, 0.5);
                self.boundingVolume = new THREE.Sphere(new THREE.Vector3(tempVec1.x, tempVec1.y, tempVec1.z), tempVec1.distanceTo(tempVec2));
            } else if (!!self.json.boundingVolume.sphere) {
                const sphere = self.json.boundingVolume.sphere;
                self.boundingVolume = new THREE.Sphere(new THREE.Vector3(sphere[0], sphere[1], sphere[2]), sphere[3]);
            } else {
                self.boundingVolume = properties.parentBoundingVolume;
            }
        } else {
            self.boundingVolume = properties.parentBoundingVolume;
        }

        function checkContent(e) {
            if (!!e.uri && e.uri.includes("json")) {
                self.hasUnloadedJSONContent++;
            } else if (!!e.url && e.url.includes("json")) {
                self.hasUnloadedJSONContent++;
            } else {
                self.hasMeshContent++;
            }
        }
        if (!!self.json.content) { //if there is a content, json or otherwise, schedule it to be loaded 
            checkContent(self.json.content);

            self.load();
        } else if (!!self.json.contents) { //if there is a content, json or otherwise, schedule it to be loaded 
            self.json.contents.forEach(e => checkContent(e))

            self.load();
            //scheduleLoadTile(this);
        }

        if (!!self.centerModel) {
            const tempSphere = new THREE.Sphere();
            if (self.boundingVolume instanceof OBB) {
                // box
                tempSphere.copy(self.boundingVolume.sphere);
            } else if (self.boundingVolume instanceof THREE.Sphere) {
                //sphere
                tempSphere.copy(self.boundingVolume);
            }

            //tempSphere.applyMatrix4(self.matrixWorld);
            if (!!this.json.boundingVolume.region) {
                self.transformWGS84ToCartesian(
                    (self.json.boundingVolume.region[0] + self.json.boundingVolume.region[2]) * 0.5,
                    (self.json.boundingVolume.region[1] + self.json.boundingVolume.region[3]) * 0.5,
                    (self.json.boundingVolume.region[4] + self.json.boundingVolume.region[5]) * 0.5,
                    tempVec1);

                tempQuaternion.setFromUnitVectors(tempVec1.normalize(), upVector.normalize());
                self.master.applyQuaternion(tempQuaternion);
                self.master.updateWorldMatrix(false, false)
            }
            tempMatrix.makeTranslation(-tempSphere.center.x * self.scale.x, -tempSphere.center.y * self.scale.y, -tempSphere.center.z * self.scale.z);
            //self.master.applyMatrix4(tempMatrix);
            self.master.matrix.multiply(tempMatrix);
            self.master.matrix.decompose(self.master.position, self.master.quaternion, self.master.scale);
        }

        self.isSetup = true;
        if (properties.onLoadCallback) properties.onLoadCallback(self);
    }

    isAbsolutePathOrURL(input) {
        // Check if it's an absolute URL with various protocols
        const urlRegex = /^(?:http|https|ftp|tcp|udp):\/\/\S+/;
        const absoluteURL = urlRegex.test(input);

        // Check if it's an absolute path
        const absolutePath = input.startsWith('/') && !input.startsWith('//');

        return absoluteURL || absolutePath;
    }

    assembleURL(root, relative) {
        // Append a slash to the root URL if it doesn't already have one
        if (!root.endsWith('/')) {
            root += '/';
        }

        const rootUrl = new URL(root);
        let rootParts = rootUrl.pathname.split('/').filter(p => p !== '');
        let relativeParts = relative.split('/').filter(p => p !== '');

        for (let i = 1; i <= rootParts.length; i++) {
            if (i >= relativeParts.length) break;
            const rootToken = rootParts.slice(rootParts.length - i, rootParts.length).join('/');
            const relativeToken = relativeParts.slice(0, i).join('/');
            if (rootToken === relativeToken) {
                for (let j = 0; j < i; j++) {
                    rootParts.pop();
                }
                break;
            }
        }


        while (relativeParts.length > 0 && relativeParts[0] === '..') {
            rootParts.pop();
            relativeParts.shift();
        }

        return `${rootUrl.protocol}//${rootUrl.host}/${[...rootParts, ...relativeParts].join('/')}`;
    }
    extractQueryParams(url, params) {
        const urlObj = new URL(url);

        // Iterate over all the search parameters
        for (let [key, value] of urlObj.searchParams) {
            params[key] = value;
        }

        // Remove the query string
        urlObj.search = '';
        return urlObj.toString();
    }
    load() {
        var self = this;
        if (self.deleted) return;
        if (!!self.json.content) {

            loadContent(self.json.content);
        } else if (!!self.json.contents) {
            self.json.contents.forEach(content => loadContent(content))
        }
        function loadContent(content) {
            let url;
            if (!!content.uri) {
                url = content.uri;
            } else if (!!content.url) {
                url = content.url;
            }
            const urlRegex = /^(?:http|https|ftp|tcp|udp):\/\/\S+/;

            if (urlRegex.test(self.rootPath)) { // url
                if (!urlRegex.test(url)) {
                    url = self.assembleURL(self.rootPath, url)
                }
            } else { //path
                if (path.isAbsolute(self.rootPath)) {
                    url = self.rootPath + path.sep + url;
                }
            }
            url = self.extractQueryParams(url, self.queryParams);
            if (self.queryParams) {
                var props = "";
                for (let key in self.queryParams) {
                    if (self.queryParams.hasOwnProperty(key)) { // This check is necessary to skip properties from the object's prototype chain
                        props += "&" + key + "=" + self.queryParams[key];
                    }
                }
                if (url.includes("?")) {
                    url += props;
                } else {
                    url += "?" + props.substring(1);
                }
            }

            if (!!url) {
                if (url.includes(".b3dm") || url.includes(".glb") || url.includes(".gltf")) {
                    self.contentURL = url;


                    self.tileLoader.get(self.abortController, url, self.uuid, self, !self.cameraOnLoad ? () => 0 : () => {
                        return self.calculateDistanceToCamera(self.cameraOnLoad);
                    }, () => self.getSiblings(),
                        self.level,
                        !!self.json.boundingVolume.region ? false : true,
                        !!self.json.boundingVolume.region,
                        self.geometricError);
                } else if (url.includes(".json")) {
                    self.tileLoader.get(self.abortController, url, self.uuid, self);
                }
            }

            
        }

    }

    loadMesh(mesh) {
        const self = this;
        if (self.deleted) {
            return;
        }
        //self.updateWorldMatrix(false, true);
        self.meshContent.add(mesh);

    }

    loadJson(json, url) {
        if (this.deleted) {
            return;
        }
        if (!!this.json.children) {
            this.jsonChildren = this.json.children;
        }

        json.rootPath = path.dirname(url);
        this.jsonChildren.push(json);
        this.hasUnloadedJSONContent--;
    }

    dispose() {

        const self = this;
        self.childrenTiles.forEach(tile => tile.dispose());

        self.deleted = true;
        if (self.abortController) self.abortController.abort();
        this.parent = null;
        this.parentTile = null;
        this.dispatchEvent({ type: 'removed' });
    }
    disposeChildren() {

        var self = this;

        self.childrenTiles.forEach(tile => tile.dispose());
        self.childrenTiles = [];

    }


    _update(camera, frustum) {
        const self = this;
        if (!self.isSetup) return;

        const visibilityBeforeUpdate = self.materialVisibility;

        if (!!self.boundingVolume && !!self.geometricError) {
            self.metric = self.calculateUpdateMetric(camera, frustum);
        }
        self.childrenTiles.forEach(child => child._update(camera, frustum));

        updateNodeVisibility(self.metric);
        updateTree(self.metric);
        trimTree(self.metric, visibilityBeforeUpdate);

        function updateTree(metric) {
            // If this tile does not have mesh content but it has children
            if (metric < 0 && self.hasMeshContent) return;

            if ((!self.hasMeshContent && self.rootPath) || (metric < self.master.geometricErrorMultiplier * self.geometricError && self.meshContent.size > 0)) {
                if (!!self.json && !!self.jsonChildren && self.childrenTiles.length != self.jsonChildren.length) {
                    loadJsonChildren();
                    return;
                }
            }
        }

        function updateNodeVisibility(metric) {

            //doesn't have a mesh content
            if (!self.hasMeshContent) {
                return;
            }
            // mesh content not yet loaded
            if (self.meshContent.size < self.hasMeshContent) {
                return;
            }

            // outside frustum
            if (metric < 0) {
                self.inFrustum = false;
                self.changeContentVisibility(!!self.loadOutsideView);
                return;
            } else {
                self.inFrustum = true;
            }

            // has no children
            if (self.childrenTiles.length == 0) {
                self.changeContentVisibility(true);
                return;
            }

            // has children
            if (metric >= self.master.geometricErrorMultiplier * self.geometricError) { // Ideal LOD or before ideal lod

                self.changeContentVisibility(true);
            } else if (metric < self.master.geometricErrorMultiplier * self.geometricError) { // Ideal LOD is past this one
                // if children are visible and have been displayed, can be hidden
                let allChildrenReady = true;
                self.childrenTiles.every(child => {

                    if (!child.isReady()) {
                        allChildrenReady = false;
                        return false;
                    }
                    return true;
                });
                if (allChildrenReady) {
                    self.changeContentVisibility(false);
                }
            }
        }

        function trimTree(metric, visibilityBeforeUpdate) {
            if (!self.hasMeshContent) return;
            if (!self.inFrustum) { // outside frustum
                self.disposeChildren();
                updateNodeVisibility(metric);
                return;
            }
            if (metric >= self.master.geometricErrorMultiplier * self.geometricError) {
                self.disposeChildren();
                updateNodeVisibility(metric);
                return;
            }

        }


        function loadJsonChildren() {
            self.jsonChildren.forEach(childJSON => {
                if (!childJSON.root && !childJSON.children && !childJSON.getChildren && !childJSON.content && !childJSON.contents) {
                    return;
                }
                let childTile = new InstancedTile({
                    parentTile: self,
                    queryParams: self.queryParams,
                    parentGeometricError: self.geometricError,
                    parentBoundingVolume: self.boundingVolume,
                    parentRefinement: self.refinement,
                    json: childJSON,
                    rootPath: self.rootPath,
                    loadOutsideView: self.loadOutsideView,
                    level: self.level + 1,
                    tileLoader: self.tileLoader,
                    cameraOnLoad: camera,
                    master: self.master,
                    centerModel: false,
                });
                self.childrenTiles.push(childTile);
                //self.add(childTile);
            });
        }

    }

    areAllChildrenLoadedAndHidden() {
        let allLoadedAndHidden = true;
        const self = this;
        this.childrenTiles.every(child => {
            if (child.hasMeshContent) {
                if (child.childrenTiles.length > 0) {
                    allLoadedAndHidden = false;
                    return false;
                }
                if (!child.inFrustum) {
                    return true;
                };
                if (!child.materialVisibility || child.meshesToDisplay != child.meshesDisplayed) {
                    allLoadedAndHidden = false;
                    return false;
                }
            } else {
                if (!child.areAllChildrenLoadedAndHidden()) {
                    allLoadedAndHidden = false;
                    return false;
                }
            }
            return true;
        });
        return allLoadedAndHidden;
    }

    /**
     * Node is ready if it is outside frustum, if it was drawn at least once or if all it's children are ready
     * @returns true if ready
     */
    isReady() {
        // if outside frustum
        if (!this.inFrustum) return true;

        // if json is not done loading
        if (this.hasUnloadedJSONContent) {
            return false;
        }
        if ((!this.hasMeshContent || this.meshContent.size == 0 || !this.materialVisibility)) {
            if (this.childrenTiles.length > 0) {
                var allChildrenReady = true;
                this.childrenTiles.every(child => {
                    if (!child.isReady()) {
                        allChildrenReady = false;
                        return false;
                    }
                    return true;
                });
                return allChildrenReady;
            } else {
                return false;
            }

        }
        // if this tile has no mesh content
        if (!this.hasMeshContent) {
            return true;
        }
        // if mesh content not yet loaded
        if (this.meshContent.size < this.hasMeshContent) {
            return false;
        }

        // if this tile has been marked to hide it's content
        if (!this.materialVisibility) {
            return false;
        }


        return true;

    }


    changeContentVisibility(visibility) {
        const self = this;
        self.materialVisibility = visibility;



    }
    calculateUpdateMetric(camera, frustum) {
        ////// return -1 if not in frustum
        if (this.boundingVolume instanceof OBB) {
            // box
            tempSphere.copy(this.boundingVolume.sphere);
            tempSphere.applyMatrix4(this.matrixWorld);
            if (!frustum.intersectsSphere(tempSphere)) return -1;
        } else if (this.boundingVolume instanceof THREE.Sphere) {
            //sphere
            tempSphere.copy(this.boundingVolume);
            tempSphere.applyMatrix4(this.matrixWorld);
            if (!frustum.intersectsSphere(tempSphere)) return -1;
        } else {
            console.error("unsupported shape");
            return -1
        }

        /////// return metric based on geometric error and distance

        const distance = Math.max(0, camera.position.distanceTo(tempSphere.center) - tempSphere.radius);

        /////// Apply the bias factor to the distance
        distance = Math.pow(distance,this.distanceBias);

        if (distance == 0) {
            return 0;
        }
        const scale = this.matrixWorld.getMaxScaleOnAxis();
        this.master._renderSize(rendererSize);
        let s = rendererSize.y;
        let fov = camera.fov;
        if (camera.aspect < 1) {
            fov *= camera.aspect;
            s = rendererSize.x;
        }

        let lambda = 2.0 * Math.tan(0.5 * fov * 0.01745329251994329576923690768489) * distance;

        return (window.devicePixelRatio * 16 * lambda) / (s * scale);

    }

    getSiblings() {
        const self = this;
        const tiles = [];
        if (!self.parentTile) return tiles;
        let p = self.parentTile;
        while (!p.hasMeshContent && !!p.parentTile) {
            p = p.parentTile;
        }
        p.childrenTiles.forEach(child => {
            if (!!child && child != self) {
                while (!child.hasMeshContent && !!child.childrenTiles[0]) {
                    child = child.childrenTiles[0];
                }
                tiles.push(child);
            }
        });
        return tiles;
    }
    calculateDistanceToCamera(camera) {
        if (this.boundingVolume instanceof OBB) {
            // box
            tempSphere.copy(this.boundingVolume.sphere);
            tempSphere.applyMatrix4(this.matrixWorld);
            //if (!frustum.intersectsSphere(tempSphere)) return -1;
        } else if (this.boundingVolume instanceof THREE.Sphere) {
            //sphere
            tempSphere.copy(this.boundingVolume);
            tempSphere.applyMatrix4(this.matrixWorld);
            //if (!frustum.intersectsSphere(tempSphere)) return -1;
        }
        else {
            console.error("unsupported shape")
        }
        return Math.max(0, camera.position.distanceTo(tempSphere.center) - tempSphere.radius);
    }

    getWorldMatrix() {
        const self = this;
        return self.matrixWorld;
    }

    transformWGS84ToCartesian(lon, lat, h, sfct) {
        const a = 6378137.0;
        const e = 0.006694384442042;
        const N = a / (Math.sqrt(1.0 - (e * Math.pow(Math.sin(lat), 2))));
        const cosLat = Math.cos(lat);
        const cosLon = Math.cos(lon);
        const sinLat = Math.sin(lat);
        const sinLon = Math.sin(lon);
        const nPh = (N + h);
        const x = nPh * cosLat * cosLon;
        const y = nPh * cosLat * sinLon;
        const z = (0.993305615557957 * N + h) * sinLat;

        sfct.set(x, y, z);
    }
}
export { InstancedTile };