Files
gemini-viewer-examples/sdk-src/viewers/BimViewer.ts

3058 lines
118 KiB
TypeScript
Raw Normal View History

2023-06-27 13:18:15 +08:00
import { TilesRenderer } from "3d-tiles-renderer";
import { find, get, includes, merge } from "lodash";
import * as THREE from "three";
import { VertexNormalsHelper } from "three/examples/jsm/helpers/VertexNormalsHelper.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { Font, FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
import { BloomPass } from "three/examples/jsm/postprocessing/BloomPass.js";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { SAOPass } from "three/examples/jsm/postprocessing/SAOPass.js";
import { SSAARenderPass } from "three/examples/jsm/postprocessing/SSAARenderPass.js";
import { SSAOPass } from "three/examples/jsm/postprocessing/SSAOPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader.js";
import { ShxFontLoader } from "../shx-parser";
import { ViewerEvent } from "./ViewerEvent";
import { BottomBar } from "src/components/bottom-bar";
import { ContextMenu, contextMenuItems } from "src/components/context-menu";
import { BimViewerDatGui } from "src/components/dat-gui";
import { CameraSettings, Settings as SettingsType, defaultSettings } from "src/components/settings";
import { DEFAULT_BIMVIEWER_TOOLBAR_CONFIG, Toolbar } from "src/components/toolbar";
import { BimViewerConfig, CameraConfig, DEFAULT_BIM_VIEWER_CONFIG, Hotpoint, ModelConfig } from "src/core/Configs";
import { matrixAutoUpdate, SectionType, layerForSelectableObjects, layerForHitableObjects } from "src/core/Constants";
import { CoordinateAxes, CoordinateAxesViewport } from "src/core/axes";
import { CanvasRender, Drawable } from "src/core/canvas";
import { CameraControlsEx } from "src/core/controls";
import { InstantiateHelper, LoadingHelper, MeshBvhHelper, RafHelper, ZoomToRectHelper } from "src/core/helpers";
import { EventInfo, InputManager, MouseButton } from "src/core/input/InputManager";
import { MeasurementData, MeasurementManager, MeasurementType } from "src/core/measure";
import { NavCubeViewport } from "src/core/navcube/NavCubeViewport";
import { AxisPlaneSection, ObjectsBoxSection, PickPlaneSection, SectionManager } from "src/core/section";
import {
CoordinateConversionUtils,
SceneUtils,
CommonUtils,
CSS2DObjectUtils,
DeviceUtils,
GroundUtils,
log,
MaterialUtils,
Batch,
MergeUtils,
MaterialInfo,
ObjectUtils,
SkyboxUtils,
TextureUtils,
Viewer3DUtils,
} from "src/core/utils";
import { BaseViewer, ViewerName } from "src/core/viewers/BaseViewer";
import { WebCam } from "src/core/webcam";
import { ViewCube } from "src/plugins/view-cube/ViewCube";
const tempVec3 = /*@__PURE__*/ new THREE.Vector3();
const tempSphere = /*@__PURE__*/ new THREE.Sphere();
export class BimViewer extends BaseViewer {
/**
* @internal
*/
name = ViewerName.BimViewer;
private timer = Date.now(); // used to log time for debugging, create it at the very begining
protected font?: Font;
/**
* @internal
*/
ambientLight?: THREE.AmbientLight;
/**
* @internal
*/
directionalLight?: THREE.DirectionalLight;
/**
* @internal
*/
hemisphereLight?: THREE.HemisphereLight;
/**
* @internal
*/
selectedObject: any | Drawable | undefined = undefined; // eslint-disable-line
/**
* @internal
*/
groundGrid?: THREE.Line;
/**
* @internal
*/
grassGround?: THREE.Mesh;
/**
* @internal
*/
sceneBackgroundColor: THREE.Color = new THREE.Color(0xebf2f7); // TODO: add it to settings
/**
* @internal
*/
skyOfGradientRamp?: THREE.Mesh;
private stats?: Stats;
/**
* @internal
*/
loadedModels: { [src: string]: { id: number; bbox?: THREE.Box3 } } = {}; // a map to store model file and id
/**
* @internal
*/
loaded3dTiles: { [src: string]: { id: number; bbox: THREE.Box3; renderer: TilesRenderer } } = {}; // a map to store 3dtiles and id
/**
* @internal
*/
pmremGenerator?: THREE.PMREMGenerator;
private perspectiveCamera?: THREE.PerspectiveCamera;
private orthoCamera?: THREE.OrthographicCamera;
private perspectiveCameraControls?: CameraControlsEx;
private orthoCameraConrols?: CameraControlsEx;
private css2dRenderer?: CSS2DRenderer; // used to render html labels in the scene
private composerRenderEnabled = true; // if we should call composer.render() in render()
private composerEnabled = false; // if composer and passes are enabled
private composer?: EffectComposer;
private renderPass?: RenderPass;
private effectFxaaPass?: ShaderPass;
private ssaoPass?: SSAOPass | ShaderPass;
private saoPass?: SAOPass;
private outlinePass?: OutlinePass;
private ssaaRenderPass?: SSAARenderPass;
private bloomPass?: BloomPass;
private unrealBloomPass?: UnrealBloomPass;
private raycaster?: THREE.Raycaster;
private cameraUpdateInterval?: NodeJS.Timer;
private savedMaterialsForOpacity?: MaterialInfo[] = [];
private section?: ObjectsBoxSection | PickPlaneSection | AxisPlaneSection;
/**
* @internal
*/
public sectionType?: string;
private sectionManager?: SectionManager;
private measurementManager?: MeasurementManager;
private zoomToRectHelper?: ZoomToRectHelper;
// private datGui?: BimViewerDatGui; // react native not support css inline
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private datGui?: any;
private shadowCameraHelper?: THREE.CameraHelper;
private directionalLightHelper?: THREE.DirectionalLightHelper;
private webcam?: WebCam;
private webcamPlane?: THREE.Mesh;
// RafHelper (requestAnimationFrame Helper) is used to improve render performance,
// With this feature, it only renders when necessary, e.g. camera position changed, model loaded, etc.
// We can disable this feature by assigning raf to undefined
private raf?: RafHelper = new RafHelper();
private clock: THREE.Clock = new THREE.Clock();
private renderEnabled = true; // used together with RafHelper
private timeoutSymbol?: symbol; // used together with RafHelper
private isFrustumInsectChecking = false;
private lastFrameExecuteTime = Date.now(); // used to limit max fps
private maxFps = 60; // used to limit max fps. < 0 means no limitation
private settings: SettingsType = defaultSettings;
private contextMenu?: ContextMenu;
private navCube?: NavCubeViewport;
private viewCube?: ViewCube;
private axes?: CoordinateAxesViewport;
private axesInScene?: CoordinateAxes;
private twoDModelCount = 0; // number of 2d models
private vertexNormalsHelpers?: THREE.Group;
/**
* @internal
*/
toolbar?: Toolbar<BimViewer>;
private bottomBar?: BottomBar;
/**
* @internal
*/
private bbox = new THREE.Box3();
private anchor?: HTMLDivElement;
private hotpointRoot?: THREE.Group;
// eslint-disable-next-line
constructor(viewerCfg: BimViewerConfig, cameraCfg?: CameraConfig) {
super(viewerCfg);
this.viewerCfg = { ...DEFAULT_BIM_VIEWER_CONFIG, ...viewerCfg } as BimViewerConfig;
log.info("[BimViewer]", "viewerCfg:", this.viewerCfg);
this.settings = defaultSettings;
this.cameraCfg = cameraCfg;
if (this.cameraCfg && this.cameraCfg.near) {
this.settings.camera.near = this.cameraCfg.near;
}
if (this.cameraCfg && this.cameraCfg.far) {
this.settings.camera.far = this.cameraCfg.far;
}
this.increaseJobCount(); // it is busy since initializing
this.init();
this.animate();
if (this.renderer) {
this.viewerContainer?.append(this.renderer.domElement);
}
this.decreaseJobCount(); // initialization done
log.info(`[BimViewer] Initialized in ${(Date.now() - this.timer) / 1000}s`);
}
/**
* Initialize everything it needs
* @internal
*/
init() {
const viewerCfg = this.viewerCfg as BimViewerConfig;
this.initThree();
if (DeviceUtils.isBrowser && !viewerCfg.context) {
this.initDom();
}
this.initEvents();
this.initControls();
}
private initThree() {
this.initScene();
this.initRenderer();
this.initCamera();
this.initLights();
}
private initDom() {
const viewerCfg = this.viewerCfg as BimViewerConfig;
this.initSpinner();
this.initCSS2DRenderer();
if (viewerCfg.enableDatGui === true) {
this.initDatGui(); // should be initialized later than sky, ground grid, etc.
}
this.initOthers();
if (viewerCfg.enableAxisGizmo === true) {
this.axes = this.initAxes();
}
if (viewerCfg.enableStats === true) {
this.stats = this.initStats();
}
if (viewerCfg.enableToolbar) {
this.toolbar = this.initToolbar();
}
if (viewerCfg.enableBottomBar) {
this.bottomBar = this.initBottomBar();
}
if (viewerCfg.enableNavCube) {
this.navCube = this.initNavCube();
}
if (viewerCfg.enableViewCube) {
this.viewCube = this.initViewCube();
}
if (viewerCfg.enableContextMenu) {
this.contextMenu = this.initContextMenu();
}
}
private initScene() {
this.scene = new THREE.Scene();
// this.scene.background = new THREE.Color(0xebf2f7)
// Find more performance tips at: https://discoverthreejs.com/tips-and-tricks/
// For performance. call .updateMatrix() manually when needed.
this.scene.matrixAutoUpdate = matrixAutoUpdate;
// When testing the performance of your apps, one of the first things youll need to do is check whether it is CPU bound, or GPU bound.
// If performance increases, then your app is GPU bound. If performance doesnt increase, your app is CPU bound.
// this.scene.overrideMaterial = new MeshBasicMaterial({ color: 'green' })
}
private initRenderer() {
this.renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(this.width, this.height);
// this.renderer.outputEncoding = THREE.sRGBEncoding;
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMappingExposure = 1;
// this.renderer.physicallyCorrectLights = true;
this.renderer.useLegacyLights = false;
this.renderer.setClearColor(0xa9a9a9, 1);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
CommonUtils.printGpuInfo(this.renderer.getContext());
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer);
this.pmremGenerator.compileEquirectangularShader();
if (this.enableOverlayRenderer) {
this.overlayRender = new CanvasRender(this);
}
this.setEnvironmentFromDataArray();
}
private initCSS2DRenderer() {
const r = new CSS2DRenderer();
r.setSize(this.width, this.height);
r.domElement.style.height = "0";
r.domElement.style.width = "0";
r.domElement.style.position = "absolute";
r.domElement.style.top = "0";
r.domElement.style.left = "0";
r.domElement.style.overflow = "visible";
r.domElement.classList.add("css2d-renderer"); // add a class so it is easier to be found
this.viewerContainer?.appendChild(r.domElement);
this.css2dRenderer = r;
}
private initCamera() {
if (!this.scene) {
return; // have to init scene first
}
// to avoid z-fighting issue, do not set near value too small!
// https://www.cnblogs.com/lst619247/p/9098845.html
this.perspectiveCamera = new THREE.PerspectiveCamera(
45,
this.width / this.height,
this.settings.camera.near,
this.settings.camera.far
);
this.perspectiveCamera.position.set(0, 100, 0);
this.scene.add(this.perspectiveCamera); // need to init scene before camera
this.camera = this.perspectiveCamera;
}
private initControls(isOrthCamera = false) {
if (!this.renderer) {
return;
}
// TODO: support touch device later, see https://github.com/tdushinwa/threejs_touchtest/blob/master/js/TrackballControls.js
DeviceUtils.printDeviceInfo();
const camera = isOrthCamera ? this.orthoCamera : this.perspectiveCamera;
if (!camera || !this.inputManager) {
return;
}
let controls: CameraControlsEx | undefined;
if (!this.perspectiveCameraControls || !this.orthoCameraConrols) {
// controls = new CameraControlsEx(camera, this.renderer.domElement);
controls = new CameraControlsEx(camera, this.inputManager as unknown as HTMLElement);
controls.dollySpeed = 0.1;
controls.dollyToCursor = true;
controls.enabled = true;
controls.keyTruckSpeed = 10;
controls.restThreshold = 0;
// controls.keys = { LEFT: '65', UP: '87', RIGHT: '68', BOTTOM: '83' } // a, w, d, s
controls.keys = {
left: "KeyD", // model goes left, camera goes right
up: "KeyE", // model goes up, camera goes down
right: "KeyA", // model goes right, camera goes left
bottom: "KeyQ", // model goes down, camera goes up
};
controls.listenToKeyEvents();
controls.update(0);
}
if (!controls) {
return;
}
if (isOrthCamera) {
this.orthoCameraConrols = controls;
controls.minZoom = 3;
// controls.maxZoom = 5;
} else {
this.perspectiveCameraControls = controls;
if (this.bbox) {
controls.minDistance = 0.1;
controls.maxDistance = this.bbox.getSize(new THREE.Vector3()).length() * 3;
}
}
this.controls = controls;
}
private initRotateToCursor() {
this.anchor = this.createAnchor();
}
private onResize = () => {
if (this.viewerContainer && this.viewerContainer.parentElement) {
this.resize(this.viewerContainer.parentElement.clientWidth, this.viewerContainer.parentElement.clientHeight);
}
};
private onKeyDown = (e: EventInfo) => {
const camera = this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera;
const controls = this.controls as CameraControlsEx;
if (!camera || !controls) {
return;
}
const sensitivity = this.settings.keyboard.sensitivity || 3;
const p = controls.getPosition(new THREE.Vector3()); // camera's position
const t = controls.getTarget(new THREE.Vector3()); // target point
const newTarget = t.clone();
if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
// keep camera's position uchanged, rotate to left (q) or right (e) around y-axis
// thus it changes target(lookAt) point
const ANGLE = sensitivity; // angle in degree
let theta = (Math.PI * ANGLE) / 180; // angle in radians
if (e.code === "ArrowLeft") {
theta = -theta; // rotate to left
}
newTarget.x = (t.x - p.x) * Math.cos(theta) - (t.z - p.z) * Math.sin(theta) + p.x;
newTarget.z = (t.z - p.z) * Math.cos(theta) + (t.x - p.x) * Math.sin(theta) + p.z;
controls.setTarget(newTarget.x, newTarget.y, newTarget.z);
} else if (e.code === "ArrowUp" || e.code === "ArrowDown") {
// keep camera's position uchanged, rotate to left (q) or right (e) around y-axis
// thus it changes target(lookAt) point's y value
const ANGLE2 = sensitivity; // angle in degree
let theta2 = (Math.PI * ANGLE2) / 180; // angle in radians
const distVec = new THREE.Vector3(t.x - p.x, t.y - p.y, t.z - p.z); // distance vector from p to t
const dist = distVec.length();
const deltaY = t.y - p.y;
if (e.code === "ArrowDown") {
// z: rotate down
theta2 = -theta2;
}
const angle = Math.asin(deltaY / dist) + theta2;
if (angle < -Math.PI / 2 || angle > Math.PI / 2) {
return; // cannot rotate that much
}
const newDeltaY = Math.sin(angle) * dist;
newTarget.y = t.y + (newDeltaY - deltaY);
controls.setTarget(newTarget.x, newTarget.y, newTarget.z);
} else if (e.code === "KeyW") {
// go forward
const ALPHA = sensitivity * 0.01;
const dist = p.distanceTo(t);
if (dist < camera.near * 10) {
// If distance is too close, better to move target position forward too, so camera can keep moving forward,
// rather than stop at the lookAt position. Let's move it to be 'camera.near' away from camera.
const tempTarget = controls.getTarget(new THREE.Vector3()).lerp(p, -camera.near / dist);
controls.setTarget(tempTarget.x, tempTarget.y, tempTarget.z);
}
p.lerp(t, ALPHA);
controls.setLookAt(p.x, p.y, p.z, t.x, t.y, t.z);
} else if (e.code === "KeyS") {
// go backward
const ALPHA = sensitivity * 0.01;
p.lerp(t, -ALPHA);
controls.setLookAt(p.x, p.y, p.z, t.x, t.y, t.z, false);
} else if (e.code === "KeyF") {
// if there is object selected, fly to it. (This is a design by Unreal Engine)
this.flyToSelectedObject();
} else if (e.code === "KeyT") {
// reset target
// When a user does rotate(or pan) operation, where is the rotate center(camera's target)?
// It depends on where is the camera's target as well as where is the camera itself.
// Assume camera is at p1, user clicked at p2, target is p3, distance between p1 and p2 is d1, distance between p1 and p3 is d2.
// In order to have a better user experience, we'll move target to p4 without changing camera's direction,
// where distance between p1 and p4 equals d1.
this.raycaster && this.raycaster.layers.set(layerForHitableObjects);
const intersections = this.getIntersections();
if (intersections.length > 0) {
const firstIntersect = intersections.find((intersect) => {
const object = intersect.object;
// exclude invisible objects
// exclude non-mesh objects, ground, outline, etc.
return object.visible && object instanceof THREE.Mesh;
});
if (firstIntersect && firstIntersect.point && this.camera && this.controls) {
const p1 = this.camera.position;
const p2 = firstIntersect.point;
const p3 = controls.getTarget(new THREE.Vector3());
const d1 = p1.distanceTo(p2);
if (d1 > camera.near && d1 < camera.far) {
// only take it as valid scenario when d1 is between near and far
const d2 = p1.distanceTo(p3);
const p4 = p1.clone().lerp(p3, d1 / d2);
controls.setTarget(p4.x, p4.y, p4.z);
}
}
}
} else if (e.code === "KeyY") {
// Make camera direction vertical with y-axis.
// It is useful when a user want to roaming horizontally with key(w/s/a/d) controls
this.flyTo(p, t.clone().setY(p.y));
}
this.enableRender();
};
private initLights() {
if (!this.scene) {
return;
}
// TODO: move light settings into project settings panel
// Maybe we can automatically calculate light direction, intensity, etc. according to models' materials
const color = 0xffffff;
const highIntensity = 1.5;
const dl = new THREE.DirectionalLight(color, highIntensity);
dl.name = "sun";
dl.castShadow = true;
dl.position.set(-2, 2, 4);
dl.shadow.autoUpdate = false;
dl.shadow.mapSize.width = 1024;
dl.shadow.mapSize.height = 1024;
this.directionalLight = dl;
this.scene.add(dl);
this.scene.add(dl.target);
// The object is only displayed, it is not necessary to call enableAll
this.directionalLightHelper = new THREE.DirectionalLightHelper(this.directionalLight);
this.directionalLightHelper.visible = false;
this.scene.add(this.directionalLightHelper);
this.shadowCameraHelper = new THREE.CameraHelper(this.directionalLight.shadow.camera);
this.shadowCameraHelper.visible = false;
this.scene.add(this.shadowCameraHelper);
this.ambientLight = new THREE.AmbientLight(0x303030);
this.hemisphereLight = new THREE.HemisphereLight(color, 0xdddddd, 0.2);
this.hemisphereLight.position.set(0, 300, 0);
this.scene.add(this.ambientLight);
this.scene.add(this.hemisphereLight);
}
/**
* Initialize mouse/pointer events
*/
private initEvents() {
if (!this.renderer) {
return;
}
const input = new InputManager(this.renderer.domElement);
this.inputManager = input;
let mousedowned = false,
mousemoved = false;
input.addEventListener("pointerdown", (e) => {
mousemoved = false;
mousedowned = true;
e.button === MouseButton.Left && this.onAnchorPointerDown(e);
});
input.addEventListener("pointermove", () => {
if (mousedowned) {
mousemoved = true;
}
});
input.addEventListener("pointerup", () => {
mousedowned = false;
this.onAnchorPointerUp();
});
let timeId: NodeJS.Timeout;
input.addEventListener("click", (e) => {
if (mousemoved) {
mousemoved = false;
return;
}
timeId && clearTimeout(timeId);
// do not run immediately, because it can be a double click
timeId = setTimeout(() => {
switch (e.button) {
case MouseButton.Left:
this.handleMouseClick(e);
break;
default:
break;
}
}, 300);
if (!this.selectedObject || (this.selectedObject && this.selectedObject instanceof THREE.Object3D)) {
this.contextMenu?.hide();
}
});
input.addEventListener("dblclick", (e) => {
if (!mousemoved && this.sectionManager?.isSectionActive() && this.measurementManager?.isMeasurementActive()) {
return;
}
timeId && clearTimeout(timeId);
this.handleMouseClick(e);
this.flyToSelectedObject();
this.enableRender();
});
input.addEventListener("keydown", (e) => {
this.onKeyDown(e);
// if (e.altKey && e.code === "KeyR") {
// // alt + R to fly to random object
// this.flyToRandomObject();
// }
});
input.addEventListener("contextmenu", (e) => {
if (mousemoved || this.sectionManager?.isSectionActive() || this.measurementManager?.isMeasurementActive()) {
return;
}
this.handleRightClick(e);
});
// auto resize now
// input.addEventListener("resize", this.onResize);
this.initRotateToCursor();
this.raycaster = new THREE.Raycaster();
}
private initDatGui() {
this.datGui = new BimViewerDatGui(this);
this.datGui.close(); // collapse it by default
}
private initOthers() {
if (!this.scene || !this.renderer || !this.camera) {
return;
}
// Read some settings from Dat.Gui. While in future,
// these default settings should be defined by project settings.
const ctrl = this.datGui && this.datGui.controls;
if (ctrl) {
// some settings are read from datGui, so it requires to initialize datGui first
if (ctrl.showGroundGrid) {
this.groundGrid = GroundUtils.createGroundGrid();
this.scene.add(this.groundGrid);
}
if (ctrl.showGrassGround) {
(async () => {
this.grassGround = await GroundUtils.createGrassGround();
this.scene && this.scene.add(this.grassGround);
this.enableRender();
})();
}
ctrl.webcam && this.enableWebCam();
// the order of passes matters, outline pass should be the last one
this.composerEnabled = ctrl.composerEnabled;
if (this.composerEnabled) {
this.enableComposer(true);
this.enableRenderPass(ctrl.renderPassEnabled);
this.enableFxaaPass(ctrl.fxaaEnabled);
this.enableSaoPass(ctrl.saoEnabled);
this.enableSsaoPass(ctrl.ssaoEnabled);
this.enableOutlinePass(ctrl.outlineEnabled);
this.enableSsaaPass(ctrl.ssaaEnabled);
this.enableBloomPass(ctrl.bloomEnabled);
this.enableUnrealBloomPass(ctrl.unrealBloomEnabled);
}
}
// erase the black outline when viewer is focused
this.renderer.domElement.style.outlineWidth = "0";
}
private initNavCube() {
const navCubeElement = document.createElement("div");
navCubeElement.id = "navCube";
const navCube = new NavCubeViewport();
if (navCube.renderer) {
navCubeElement.appendChild(navCube.renderer.domElement);
navCube.setHostViewer(this);
}
this.widgetContainer?.appendChild(navCubeElement);
return navCube;
}
private initViewCube() {
const viewCubeElement = document.createElement("div");
viewCubeElement.id = "viewCube";
this.widgetContainer?.appendChild(viewCubeElement);
const viewCube = new ViewCube({
containerId: viewCubeElement.id,
});
viewCube.setHostViewer(this);
return viewCube;
}
private initAxes() {
const axesDiv = document.createElement("div");
axesDiv.classList.add("axesRenderer");
const cav = new CoordinateAxesViewport(axesDiv, this.camera as THREE.Camera);
this.widgetContainer?.append(axesDiv);
// Some users want a axes in scene, so they know how a model is defined.
// We don't want to add one more option for BimViewer, so it will be controlled together by "enableAxisGizmo"
this.axesInScene = new CoordinateAxes(false);
this.scene?.add(this.axesInScene);
return cav;
}
private initStats() {
const stats = new Stats() as any; // eslint-disable-line
stats.setMode(0); // 0: fps, 1: ms
const div = document.createElement("div");
div.classList.add("statsOutput");
div.appendChild(stats.domElement);
this.widgetContainer?.append(div);
return stats;
}
private initContextMenu() {
const contextMenu = new ContextMenu({
items: contextMenuItems,
context: { bimViewer: this, toolbar: this.toolbar, section: this.section },
container: this.widgetContainer,
});
window.oncontextmenu = (event: MouseEvent) => event.preventDefault();
return contextMenu;
}
private initToolbar() {
const viewerCfg = this.viewerCfg as BimViewerConfig;
return new Toolbar<BimViewer>(this, merge({}, DEFAULT_BIMVIEWER_TOOLBAR_CONFIG, viewerCfg.toolbarMenuConfig));
}
private initBottomBar() {
return new BottomBar(this);
}
/**
* If there is any 2d model loaded
* @internal
*/
get has2dModel() {
return this.twoDModelCount > 0;
}
private showContextMenu(event: EventInfo) {
if (!this.contextMenu || !this.widgetContainer) {
return;
}
this.raycaster && this.raycaster.layers.set(layerForHitableObjects);
const intersections = this.getAllIntersections(event);
log.debug("[BimViewer] showContextMenu intersections = ", intersections);
const firstIntersect = find(intersections, (intersect) => {
const object = intersect.object;
// exclude invisible objects
// exclude non-mesh objects, ground, outline, etc.
return object instanceof THREE.Mesh && object.visible;
});
const context = this.contextMenu.context;
const instanceId = (firstIntersect as THREE.Intersection)?.instanceId;
const faceIndex = firstIntersect?.faceIndex;
let batchId = undefined;
if (faceIndex) {
batchId = MergeUtils.getBatchIdByFaceIndex(firstIntersect?.object as THREE.Mesh, faceIndex);
}
this.contextMenu.context = { ...context, hit: firstIntersect?.object, instanceId, batchId };
const { x, y } = event;
this.contextMenu.show(x, y);
}
private handleRightClick(event: EventInfo) {
this.showContextMenu(event);
}
private sycnCameraPosition(
src: THREE.PerspectiveCamera | THREE.OrthographicCamera,
dest: THREE.PerspectiveCamera | THREE.OrthographicCamera
) {
const p = src.position;
dest.position.set(p.x, p.y, p.z);
if (this.scene) {
const t = this.scene.position; // src.target
dest.lookAt(t);
}
}
private sycnControls(src: CameraControlsEx, dest: CameraControlsEx) {
const t = src.getTarget(new THREE.Vector3());
const p = src.getPosition(new THREE.Vector3());
dest.setPosition(p.x, p.y, p.z);
dest.setTarget(t.x, t.y, t.z);
}
setToOrthographicCamera(isOrthCamera = false) {
if (!this.scene || !this.controls) {
return;
}
const pc = this.perspectiveCamera;
const pcc = this.perspectiveCameraControls;
let oc = this.orthoCamera;
let occ = this.orthoCameraConrols;
if (isOrthCamera) {
if (!oc) {
oc = new THREE.OrthographicCamera(
-this.width / 2,
this.width / 2,
this.height / 2,
-this.height / 2,
this.settings.camera.near,
this.settings.camera.far
);
oc.position.set(0, 100, 0);
oc.zoom = 10; // it seems 10 works better, but don't know how to set a better one!
oc.updateProjectionMatrix();
this.scene && this.scene.add(oc); // need to init scene before camera
this.orthoCamera = oc;
this.frustumSize = this.width;
}
if (!occ) {
this.initControls(true);
occ = this.orthoCameraConrols;
}
if (pc) {
this.sycnCameraPosition(pc, oc);
oc.zoom = 10;
oc.updateProjectionMatrix();
}
if (pcc && occ && pc) {
this.sycnControls(pcc, occ);
}
this.camera = oc;
this.controls = occ;
} else {
if (pc && oc) {
this.sycnCameraPosition(oc, pc);
}
if (pcc && occ && oc) {
this.sycnControls(occ, pcc);
}
this.camera = pc;
this.controls = pcc;
}
this.axes?.setHostCamera(this.camera as THREE.Camera);
this.resize(this.width, this.height); // trigger camera to update properly
this.dispatchEvent(ViewerEvent.CameraChanged);
}
protected animate() {
this.requestAnimationFrameHandle = requestAnimationFrame(this.animate.bind(this));
const delta = this.clock.getDelta();
const updated = this.controls && this.controls.update(delta);
if (this.maxFps > 0) {
const delta = Date.now() - this.lastFrameExecuteTime;
if (delta < 1000 / this.maxFps) {
return;
}
this.lastFrameExecuteTime = Date.now();
}
this.webcam && this.webcam.animate();
if (this.scene && this.camera) {
// If parent container change ,auto resize
if (this.viewerContainer && this.viewerContainer.parentElement) {
const { width, height } = this.viewerContainer.parentElement.getBoundingClientRect();
const needResize = this.width !== width || this.height !== height;
if (needResize) {
this.resize(width, height);
}
}
if (this.renderEnabled || updated) {
//this.updateRaycasterThreshold();
this.update3dTiles();
this.renderer?.render(this.scene, this.camera);
// Improves rendering framerate
if (this.hotpointRoot && this.hotpointRoot.children.length > 0) {
this.css2dRenderer?.render(this.scene, this.camera);
}
this.dispatchEvent(ViewerEvent.AfterRender);
}
}
if (this.composerRenderEnabled && this.composer && this.composerEnabled) {
this.composer.render();
this.composerRenderEnabled = false;
}
this.frustrumCullingByModelBBox();
this.stats?.update();
this.bottomBar?.update();
}
private update3dTiles() {
if (!this.camera) {
return;
}
const tileSets = Object.values(this.loaded3dTiles);
if (tileSets.length === 0) {
return;
}
// The camera matrix is expected to be up to date before calling tilesRenderer.update
this.camera.updateMatrixWorld();
tileSets.forEach((obj: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => {
obj.renderer.update();
});
}
/**
* This is a method called in animate() in order to optimize rendering speed.
* The idea is to hide any model out of view frustrum.
*/
private frustrumCullingByModelBBox() {
const frustum = new THREE.Frustum();
const projScreenMatrix = new THREE.Matrix4();
this.isFrustumInsectChecking = true;
if (this.camera) {
projScreenMatrix.multiplyMatrices(this.camera.projectionMatrix, this.camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(projScreenMatrix);
Object.values(this.loadedModels).forEach((obj: { id: number; bbox?: THREE.Box3 }) => {
const model = this.scene && this.scene.getObjectById(obj.id);
const bbox = obj.bbox;
if (model && bbox && this.scene) {
// adds userData to model
// userData: {
// _visible: boolean,
// userConfigVisibility: boolean
// }
// userConfigVisibility is a flag to indicate if model's visibility ever changed by user
// from BimTree, LayerManager, etc. If ever changed, then frustrumCullingByModelBBox shouldn't
// work for thus model any more.
if (typeof model.userData._visible === "undefined") {
model.userData._visible = true;
Object.defineProperties(model, {
visible: {
set: (val) => {
// eslint-disable-line
model.userData._visible = val;
if (!this.isFrustumInsectChecking) {
model.userData.userConfigVisibility = true;
}
},
get: () => model.userData._visible,
},
});
}
if (typeof model.userData.userConfigVisibility === "undefined") {
model.visible = frustum.intersectsBox(bbox);
}
}
});
// 3dTiles
Object.values(this.loaded3dTiles).forEach((obj: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => {
const model = obj.renderer.group;
if (model && !obj.bbox.isEmpty() && this.scene) {
// adds userData to model
// userData: {
// _visible: boolean,
// userConfigVisibility: boolean
// }
// userConfigVisibility is a flag to indicate if model's visibility ever changed by user
// from BimTree, LayerManager, etc. If ever changed, then frustrumCullingByModelBBox shouldn't
// work for thus model any more.
if (typeof model.userData._visible === "undefined") {
model.userData._visible = true;
Object.defineProperties(model, {
visible: {
set: (val) => {
// eslint-disable-line
model.userData._visible = val;
if (!this.isFrustumInsectChecking) {
model.userData.userConfigVisibility = true;
}
},
get: () => model.userData._visible,
},
});
}
if (typeof model.userData.userConfigVisibility === "undefined") {
model.visible = frustum.intersectsBox(obj.bbox);
}
}
});
}
this.isFrustumInsectChecking = false;
}
/**
* In order to have a better performance, it should only render when necessary.
* Usually, we should enable render for these cases:
* - Anything added to, removed from scene, or objects' position, scale, rotation, opacity, material, etc. changed
* - Anything selected/unselected
* - Camera changed
* - Render area resized
* @internal
*/
enableRender = (time = 1000) => {
this.renderEnabled = true;
if (!this.raf) {
return;
}
this.timeoutSymbol && this.raf.clearTimeout(this.timeoutSymbol);
this.timeoutSymbol = this.raf.setTimeout(() => {
this.renderEnabled = false;
// when main render process is done, enable composer render
this.composerRenderEnabled = true;
}, time);
};
destroy() {
this.inputManager?.removeEventListener();
if (this.datGui && this.datGui.gui) {
this.datGui.beforeDestroy();
this.datGui = undefined;
}
const wc = this.webcamPlane;
if (this.scene && wc) {
this.scene.remove(wc);
wc.geometry.dispose();
(wc.material as THREE.Material).dispose();
this.webcamPlane = undefined;
}
this.webcam = undefined;
this.composer = undefined;
this.renderPass = undefined;
this.effectFxaaPass = undefined;
this.saoPass = undefined;
this.ssaoPass = undefined;
this.outlinePass = undefined;
this.ambientLight = undefined;
this.directionalLight = undefined;
this.hemisphereLight = undefined;
if (this.directionalLightHelper) {
this.directionalLightHelper.dispose();
this.directionalLightHelper = undefined;
}
if (this.controls) {
this.disposeRotateToCursor();
this.controls.dispose();
this.controls = undefined;
}
if (this.perspectiveCameraControls) {
this.perspectiveCameraControls.dispose();
this.perspectiveCameraControls = undefined;
}
if (this.shadowCameraHelper) {
this.shadowCameraHelper.dispose();
this.shadowCameraHelper = undefined;
}
if (this.css2dRenderer) {
this.viewerContainer?.removeChild(this.css2dRenderer.domElement);
this.css2dRenderer = undefined;
}
this.stats = undefined;
this.raycaster = undefined;
this.selectedObject = undefined;
if (this.groundGrid) {
this.groundGrid.geometry.dispose();
(this.groundGrid.material as THREE.Material).dispose();
this.groundGrid.clear();
this.groundGrid = undefined;
}
if (this.grassGround) {
this.grassGround.geometry.dispose();
(this.grassGround.material as THREE.Material).dispose();
this.grassGround.clear();
this.grassGround = undefined;
}
if (this.skyOfGradientRamp) {
this.skyOfGradientRamp.geometry.dispose();
// TODO: don't know why ShaderMaterial still cannot be released from memory
(this.skyOfGradientRamp.material as THREE.Material).dispose();
this.skyOfGradientRamp.clear();
this.skyOfGradientRamp = undefined;
}
this.savedMaterialsForOpacity = undefined;
this.section = undefined;
this.sectionType = undefined;
Object.keys(this.loadedModels).forEach((key) => {
delete this.loadedModels[key];
});
Object.values(this.loaded3dTiles).forEach((model: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => {
model.renderer.dispose();
});
this.loaded3dTiles = {};
this.perspectiveCamera = undefined;
this.perspectiveCameraControls = undefined;
if (this.raf) {
this.timeoutSymbol && this.raf.clearTimeout(this.timeoutSymbol);
this.raf = undefined;
}
if (this.contextMenu) {
this.contextMenu.destroy();
this.contextMenu = undefined;
}
if (this.navCube) {
this.navCube.dispose();
this.navCube = undefined;
}
if (this.viewCube) {
this.viewCube.destroy();
this.viewCube = undefined;
}
if (this.axes) {
this.axes.dispose();
this.axes = undefined;
}
if (this.axesInScene) {
this.axesInScene.clear();
this.axesInScene = undefined;
}
if (this.toolbar) {
this.toolbar.destroy();
this.toolbar = undefined;
}
this.font = undefined;
if (this.pmremGenerator) {
this.pmremGenerator.dispose();
this.pmremGenerator = undefined;
}
this.measurementManager?.destroy();
this.measurementManager = undefined;
this.zoomToRectHelper?.destroy();
this.zoomToRectHelper = undefined;
this.sectionManager?.destroy();
this.sectionManager = undefined;
super.destroy();
}
/**
* Loads a 3d model from local
* @internal
*/
async loadLocalModel(
url: string,
modelCfg: ModelConfig,
manager?: THREE.LoadingManager,
onProgress?: (event: ProgressEvent) => void
): Promise<void> {
this.timer = Date.now();
// it's better to increase jobCount in individule loadXxx method,
// but for less modification, let's do it here.
this.increaseJobCount();
try {
const loadingHelper = new LoadingHelper(manager);
// font is used for dxf for now
if (this.font) {
loadingHelper.setFont(this.font as Font);
}
const object = await loadingHelper.loadLocalModel(url, modelCfg.src, onProgress);
if (object) {
this.applyOptionsAndAddToScene(url, object, modelCfg);
return Promise.resolve();
}
} catch (error) {
const msg = `Error loading ${modelCfg.src}`;
log.error(msg, error);
return Promise.reject(msg);
} finally {
this.decreaseJobCount();
}
return Promise.reject();
}
/**
* Loads a 3d model
*/
async loadModel(modelCfg: ModelConfig, onProgress?: (event: ProgressEvent) => void): Promise<void> {
this.timer = Date.now();
// it's better to increase jobCount in individule loadXxx method,
// but for less modification, let's do it here.
this.increaseJobCount();
try {
const loadingHelper = new LoadingHelper();
// font is used for dxf for now
if (this.font) {
loadingHelper.setFont(this.font as Font);
}
const object = await loadingHelper.loadModel(modelCfg.src, modelCfg.fileFormat, onProgress);
if (object) {
this.applyOptionsAndAddToScene(modelCfg.src, object, modelCfg);
return Promise.resolve();
}
} catch (error) {
const msg = `Error loading ${modelCfg.src}`;
log.error(msg, error);
return Promise.reject(msg);
} finally {
this.decreaseJobCount();
}
return Promise.reject();
}
/**
* Loads 3dtiles
* TODO: Temporarily does not support 3dtiles version 1.0 above
* The coordinate system is not processed yet
* @internal
*/
async load3dTiles(modelCfg: ModelConfig): Promise<void> {
this.timer = Date.now();
// it's better to increase jobCount in individule loadXxx method,
// but for less modification, let's do it here.
this.increaseJobCount();
const tilesRenderer = new TilesRenderer(modelCfg.src);
const bbox = new THREE.Box3();
const object = await new Promise<THREE.Object3D | undefined>((resolve, reject) => {
tilesRenderer.onLoadTileSet = () => {
const success = tilesRenderer.getBounds(bbox);
if (!success) {
if (!tilesRenderer.getBoundingSphere(tempSphere)) {
log.warn(`[BimViewer] Can't get the correct bounding box of 3dTiles '${modelCfg.src}'!`);
reject();
} else {
tempSphere.getBoundingBox(bbox);
}
}
log.debug(bbox);
// avoid multiple calls
tilesRenderer.onLoadTileSet = null;
const object = tilesRenderer.group;
log.debug(object);
resolve(object);
};
tilesRenderer.onLoadModel = (model: THREE.Object3D) => {
model.traverse((obj) => {
if (!matrixAutoUpdate && obj.matrixAutoUpdate) {
obj.matrixAutoUpdate = matrixAutoUpdate;
obj.updateMatrix();
}
// eslint-disable-next-line
if ((obj as any).isMesh) {
obj.castShadow = true;
obj.receiveShadow = true;
}
// eslint-disable-next-line
if ((obj as any).geometry) {
obj.layers.enableAll();
}
});
model.updateWorldMatrix(false, true);
this.updateDirectionalLightShadow();
this.enableRender();
};
tilesRenderer.setCamera(this.camera as THREE.Camera);
tilesRenderer.setResolutionFromRenderer(this.camera as THREE.Camera, this.renderer as THREE.WebGLRenderer);
tilesRenderer.update();
});
if (object) {
const t = Date.now();
let modelId = modelCfg.modelId || modelCfg.src;
if (this.loaded3dTiles[modelId]) {
let i = 1;
while (this.loaded3dTiles[`${modelId}_${i}`]) {
i++;
}
modelId = `${modelId}_${i}`;
log.warn(`[BimViewer] 3dTiles '${modelId}' is loaded more than once!`);
}
this.loaded3dTiles[modelId] = { id: object.id, bbox, renderer: tilesRenderer };
if (modelCfg.matrix && modelCfg.matrix.length === 16) {
const mat = new THREE.Matrix4();
mat.elements = modelCfg.matrix;
object.applyMatrix4(mat);
} else {
const pos = modelCfg.position || [0, 0, 0];
const rot = modelCfg.rotation || [0, 0, 0];
const scale = modelCfg.scale || [1, 1, 1];
object.position.set(pos[0], pos[1], pos[2]);
object.rotation.set((rot[0] * Math.PI) / 180.0, (rot[1] * Math.PI) / 180.0, (rot[2] * Math.PI) / 180.0);
object.scale.set(scale[0], scale[1], scale[2]);
}
object.matrixAutoUpdate = matrixAutoUpdate;
object.updateMatrix();
object.updateMatrixWorld(true);
this.scene?.add(object);
bbox.applyMatrix4(object.matrix);
this.computeBoundingBox();
this.tryAdjustDirectionalLight();
const loadedModelsCount = Object.keys(this.loadedModels).length;
const isFirstModel = loadedModelsCount === 0 && Object.keys(this.loaded3dTiles).length === 1;
if (isFirstModel) {
const ctrl = this.datGui && this.datGui.controls;
this.regenSkyOfGradientRamp();
if (ctrl && ctrl.showGroundGrid) {
this.regenGroundGrid();
}
this.tryAdjustCameraNearAndFar();
this.goToHomeView(); // only go to home view once, when the first model loaded
}
log.info(`[BimViewer] Added 3dTiles '${modelCfg.src}' to scene in ${(Date.now() - t) / 1000}s`);
this.enableRender();
this.decreaseJobCount();
return Promise.resolve();
}
this.decreaseJobCount();
return Promise.reject();
}
/**
* Sets font.
* This needs to be called before loading a dxf, it won't affect any loaded text.
* It accepts shx or typeface formats. For typeface, it only support passing in 1 font file in the array for now.
* @param urls font file urls
*/
async setFont(urls: string[]) {
const t = Date.now();
if (ShxFontLoader.isShxFile(urls[0])) {
this.font = await new ShxFontLoader().loadAsync(urls);
} else {
if (urls.length > 1) {
log.warn(`[BimViewer] Only support 1 typeface font file for now, others will be ignored!`);
}
this.font = await new FontLoader().loadAsync(urls[0]);
}
log.info(`[BimViewer] Font file(s) load time in ${(Date.now() - t) / 1000}s`);
}
/**
* Sets decoder path for draco loader.
* Draco decoder will be used if a model is draco encoded.
* @param decoderPath e.g., "three/js/libs/draco/gltf/"
* @internal
*/
setDracoDecoderPath(path: string) {
LoadingHelper.setDracoDecoderPath(path);
}
/**
* Applies options and add object to scene.
*/
private applyOptionsAndAddToScene = (url: string, object: THREE.Object3D, modelCfg: ModelConfig) => {
log.info(`[BimViewer] '${url}' is loaded in ${(Date.now() - this.timer) / 1000}s, adding to scene...`);
this.timer = Date.now();
const fileName = modelCfg.src && modelCfg.src.toLowerCase();
if (fileName && fileName.endsWith("dxf")) {
this.twoDModelCount++;
}
if (modelCfg.matrix && modelCfg.matrix.length === 16) {
const mat = new THREE.Matrix4();
mat.elements = modelCfg.matrix;
object.applyMatrix4(mat);
} else {
const pos = modelCfg.position || [0, 0, 0];
const rot = modelCfg.rotation || [0, 0, 0];
const scale = modelCfg.scale || [1, 1, 1];
object.position.set(pos[0], pos[1], pos[2]);
object.rotation.set((rot[0] * Math.PI) / 180.0, (rot[1] * Math.PI) / 180.0, (rot[2] * Math.PI) / 180.0);
object.scale.set(scale[0], scale[1], scale[2]);
}
// Set object.matrixAutoUpdate = matrixAutoUpdate for static or rarely moving objects and manually call
// object.updateMatrix() whenever their position/rotation/quaternion/scale are updated.
object.matrixAutoUpdate = matrixAutoUpdate;
object.updateMatrix();
object.traverse((obj) => {
if (!matrixAutoUpdate && obj.matrixAutoUpdate) {
obj.matrixAutoUpdate = matrixAutoUpdate;
obj.updateMatrix();
}
});
const instantiate = modelCfg.instantiate;
const merge = modelCfg.merge;
if (instantiate) {
// load and display first, then do instantiation
setTimeout(() => {
this.instantiate(object);
// If we do instantiate, better to goToHomeView() and regenSky() after instantiate is done,
// otherwise, the bounding box could be wrong.
// This makes loading take longer time, since we add to scene after instantiate!
setTimeout(() => {
if (merge) {
this.merge(object);
}
this.addLoadedModelToScene(object, modelCfg);
}, 0);
}, 0);
} else if (merge) {
setTimeout(() => {
this.merge(object);
// If we do instantiate, better to goToHomeView() and regenSky() after instantiate is done,
// otherwise, the bounding box could be wrong.
// This makes loading take longer time, since we add to scene after instantiate!
setTimeout(() => this.addLoadedModelToScene(object, modelCfg), 0);
}, 0);
} else {
this.addLoadedModelToScene(object, modelCfg);
}
};
/**
* Add newly added object to scene.
* Also, usually(but not always) we should regenerate sky and go to home view
* @param object
*/
private addLoadedModelToScene(object: THREE.Object3D, modelCfg: ModelConfig) {
if (!this.scene) {
return;
}
if (modelCfg.merge) {
const t = Date.now();
// If highlighting is not required, the original index data may not be preserved
MeshBvhHelper.createMeshBvhAsync([object], { saveOriginalIndex: true });
log.info(`[BimViewer] Creates mesh bvh cost ${(Date.now() - t) / 1000}s`);
}
// enable shadow
object.traverse((obj) => {
// eslint-disable-next-line
if ((obj as any).isMesh) {
obj.castShadow = true;
obj.receiveShadow = true;
}
// eslint-disable-next-line
if ((obj as any).geometry) {
obj.layers.enableAll();
}
});
this.scene.add(object);
const bbox = new THREE.Box3().setFromObject(object);
// It is a valid case when a model is loaded more than once,
// but we need to handle this.loadedModels' 'key' properly.
let modelId = modelCfg.modelId || modelCfg.src;
if (this.loadedModels[modelId]) {
let i = 1;
while (this.loadedModels[`${modelId}_${i}`]) {
i++;
}
modelId = `${modelId}_${i}`;
log.warn(`[BimViewer] Model '${modelId}' is loaded more than once!`);
}
this.loadedModels[modelId] = { id: object.id, bbox };
this.computeBoundingBox();
this.tryAdjustDirectionalLight();
const modelIds = Object.values(this.loadedModels).map((obj) => obj.id);
Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id));
const isFirstModel = !modelIds || modelIds.length <= 1;
if (isFirstModel) {
if (this.has2dModel) {
// this.scene.background = new THREE.Color(0x000000);
this.setToOrthographicCamera(true);
if (this.skyOfGradientRamp) {
this.scene.remove(this.skyOfGradientRamp);
}
} else {
const ctrl = this.datGui && this.datGui.controls;
this.regenSkyOfGradientRamp();
if (ctrl && ctrl.showGroundGrid) {
this.regenGroundGrid();
}
}
this.tryAdjustCameraNearAndFar();
this.goToHomeView(); // only go to home view once, when the first model loaded
}
// this.scene.add(bbox);
if (modelCfg.edges) {
ObjectUtils.addOutlines(object);
}
log.info(`[BimViewer] Added '${modelCfg.src}' to scene in ${(Date.now() - this.timer) / 1000}s`);
this.enableRender();
// this.decreaseJobCount();
this.dispatchEvent(ViewerEvent.ModelLoaded);
}
/**
* We won't set a opacity directly, because that way will lose model's original opacity value
* @param isAdd is add or remove the opacity we added
* @param opacity
* @internal
*/
public addOrRemoveObjectOpacity(isAdd = true, opacity = 0.3, includeObjectIds?: number[], excludeObjectIds?: number[]) {
// store informations into materials, so we can revert them
if (!this.savedMaterialsForOpacity) {
this.savedMaterialsForOpacity = [];
}
if (!this.scene) {
return;
}
const scene = this.scene;
const materialInfoList: MaterialInfo[] = [];
const modelIds = Object.values(this.loadedModels).map((obj) => obj.id);
Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id));
modelIds.forEach((id) => {
if (isAdd) {
if (this.savedMaterialsForOpacity && this.savedMaterialsForOpacity.length > 0) {
ObjectUtils.revertObjectOpacityById(scene, id, this.savedMaterialsForOpacity);
}
const list = ObjectUtils.setObjectOpacityById(scene, id, opacity, includeObjectIds, excludeObjectIds);
materialInfoList.push(...list);
} else if (this.savedMaterialsForOpacity) {
ObjectUtils.revertObjectOpacityById(scene, id, this.savedMaterialsForOpacity);
}
});
if (isAdd) {
this.savedMaterialsForOpacity = materialInfoList;
} else {
this.savedMaterialsForOpacity = [];
}
this.enableRender();
}
/**
* @internal
*/
public hasTransparentObject(): boolean {
return !!(this.savedMaterialsForOpacity && this.savedMaterialsForOpacity.length > 0);
}
/**
* @internal
*/
public showVertexNormals(show: boolean, size = 0.1) {
if (show) {
if (!this.vertexNormalsHelpers) {
this.vertexNormalsHelpers = new THREE.Group();
}
this.scene?.traverseVisible((obj: THREE.Object3D) => {
const objectNamesToExclude = ["SKYBOX", "GROUND_GRID", "GRASS_GROUND", "BIM_VIEWER_BOX_HELPER"];
if (obj instanceof THREE.Mesh && !objectNamesToExclude.includes(obj.name)) {
const normal = obj.geometry.attributes.normal;
if (!normal) {
return;
}
const helper = new VertexNormalsHelper(obj, size, 0xff0000);
this.vertexNormalsHelpers?.add(helper);
}
});
this.scene?.add(this.vertexNormalsHelpers);
} else if (this.vertexNormalsHelpers) {
this.scene?.remove(this.vertexNormalsHelpers);
this.vertexNormalsHelpers = undefined;
}
}
// resize render area
// if no width or height passed in, use window.innerWidth/window.innerHeight instead
protected resize(width: number, height: number) {
// handle window resize event
super.resize(width, height);
if (height > 0) {
if (this.composer) {
this.composer.setSize(width, height);
}
if (this.effectFxaaPass) {
// eslint-disable-next-line
this.effectFxaaPass.uniforms["resolution"].value.set(1 / width, 1 / height);
}
}
this.enableRender();
}
/**
* @internal
*/
public getRaycaster(): THREE.Raycaster | undefined {
return this.raycaster;
}
/**
* @internal
*/
public getRaycastableObjectsByMouse(event?: EventInfo): THREE.Object3D[] {
let objects: THREE.Object3D[] = [];
if (!this.raycaster || !this.camera || !this.scene || !event || !this.viewerContainer) {
return objects;
}
const screenPoint = new THREE.Vector2(event.x, event.y);
const coords = CoordinateConversionUtils.screenPoint2NdcPoint(screenPoint, this.camera, this.viewerContainer);
this.camera.updateMatrixWorld();
this.raycaster.setFromCamera(coords, this.camera);
objects = this.getRaycastableObjects();
return objects;
}
private getRaycastableObjects(): THREE.Object3D[] {
const objects: THREE.Object3D[] = [];
Object.values(this.loadedModels).forEach((obj) => {
const object = this.scene && this.scene.getObjectById(obj.id);
if (object && object.visible) {
objects.push(object);
}
});
Object.values(this.loaded3dTiles).forEach((obj) => {
const object = this.scene && this.scene.getObjectById(obj.id);
if (object && object.visible) {
objects.push(object);
}
});
return objects;
}
/**
* Gets intersections by given mouse location.
* If no MouseEvent is passed in, use (0, 0) as the raycaster's origin.
*/
private getIntersections(event?: EventInfo): THREE.Intersection[] {
const objects = this.getRaycastableObjectsByMouse(event);
let intersects = (this.raycaster && this.raycaster.intersectObjects(objects, true)) || [];
//filter clipp object
if (this.renderer && this.renderer.clippingPlanes.length > 0) {
intersects = intersects.filter((intsect) => {
return this.renderer?.clippingPlanes.every(function (plane) {
return plane.distanceToPoint(intsect.point) > 0;
});
});
}
return intersects;
}
private getAllIntersections(event?: EventInfo): THREE.Intersection[] {
if (!this.raycaster || !this.camera || !this.scene || !this.viewerContainer) {
return [];
}
const screenPoint = new THREE.Vector2(event?.x, event?.y);
const coords = CoordinateConversionUtils.screenPoint2NdcPoint(screenPoint, this.camera, this.viewerContainer);
this.raycaster.setFromCamera(coords, this.camera);
const objects: THREE.Object3D[] = this.scene.children;
let intersects = this.raycaster.intersectObjects(objects, true) || [];
//filter clipp object
if (this.renderer && this.renderer.clippingPlanes.length > 0) {
intersects = intersects.filter((intsect) => {
return this.renderer?.clippingPlanes.every(function (plane) {
return plane.distanceToPoint(intsect.point) > 0;
});
});
}
return intersects;
}
/**
* Handles mouse click event
*/
private handleMouseClick(event: EventInfo) {
// when measure is enabled, disable highlight/select feature
if (this.measurementManager?.isMeasurementActive() || this.sectionManager?.isSectionActive()) {
return;
}
const t = Date.now();
this.raycaster && this.raycaster.layers.set(layerForSelectableObjects);
const intersections = this.getIntersections(event);
log.debug(`[BimViewer] getIntersections costs ${(Date.now() - t) / 1000}s`);
const firstIntersect = intersections.find((intersect) => {
const object = intersect.object;
// exclude invisible objects
// exclude non-selectable non-mesh objects, ground, outline, etc. It's kind of complex, but gonna be wired if user can select another object behand a mesh.
return object.visible && (object.userData.selectable !== false || object instanceof THREE.Mesh);
});
let object: THREE.Object3D | Drawable | undefined = (firstIntersect && firstIntersect.object) || undefined;
let instanceId; // used for InstancedMesh
let batchId; // used for merged mesh
if (object) {
if (object instanceof THREE.InstancedMesh) {
instanceId = (firstIntersect as THREE.Intersection).instanceId;
if (
this.selectedObject &&
this.selectedObject.id === object.id &&
this.selectedObject.userData.instanceId === instanceId
) {
// if the same InstancedMesh is selected and with the same instanceId, then deselect it
object = undefined;
}
} else if (MergeUtils.isMergedMesh(object as THREE.Mesh)) {
const faceIndex = (firstIntersect && firstIntersect.faceIndex) || -1;
if (faceIndex >= 0) {
batchId = MergeUtils.getBatchIdByFaceIndex(object as THREE.Mesh, faceIndex);
if (
this.selectedObject &&
this.selectedObject.id === object.id &&
this.selectedObject.userData.batchId === batchId
) {
object = undefined;
}
} else {
object = undefined;
}
} else if (this.selectedObject && this.selectedObject.id === object.id) {
// if one object is selected twice, deselect it
object = undefined;
}
}
if (intersections.length > 0 && intersections[0].point) {
const drawables = this.overlayRender?.getDrawablesByPosition(intersections[0].point, this.raycaster);
if (drawables && drawables.length > 0) {
// this.clearSelection();
// drawables[0].selected = true;
// this.selectedObject = drawables[0];
object = drawables[0];
}
}
if (this.selectedObject) {
this.clearSelection();
}
object ? this.selectObject(object, instanceId, batchId) : this.clearSelection();
}
/**
* Select or unselect an object.
* It doesn't support selecting more than one objects.
* It doesn't support selecting a parent object which doesn't have material itself.
* In order to support de-select, we'll need to store some information, we do this via userData:
* For InstancedMesh, there are two cases:
* 1) One Mesh in InstancedMesh is selected
* it adds following to selected object: userData \{
* instanceId: number,
* originalMatrix: THREE.Matrix4,
* clonedMesh: THREE.Mesh
* \}
* 2) The whole InstancedMesh is selected. This case is no different from a normal Mesh is selected, so:
* For Mesh, it adds: userData \{
* originalMaterial: THREE.Material
* \}
* @param object
* @param instanceId pass in instanceId if an InstancedMesh is selected
* @param depthTest set to false if caller want to make sure user can see it. When an object is
* selected by user manually, we don't need to make sure user can see it. While if selection is
* made by program, we parbably need to make sure user can see it, in other words, the selected
* object won't be blocked by other objects.
* @internal
*/
public selectObject(
object?: THREE.Object3D | Drawable,
instanceId?: number,
batchId?: number,
depthTest: boolean | undefined = undefined
) {
if (object instanceof Drawable) {
this.selectedObject = object;
this.selectedObject.selected = true;
this.enableRender();
return;
}
// revert last selected object's material if any
if (this.selectedObject) {
const userData = this.selectedObject.userData;
if (userData.instanceId != null && userData.originalMatrix && userData.clonedMesh) {
this.scene && this.scene.remove(userData.clonedMesh); // clear the cloned mesh
const im = this.selectedObject as THREE.InstancedMesh;
im.setMatrixAt(userData.instanceId, userData.originalMatrix); // revert the matrix
im.instanceMatrix.needsUpdate = true;
im.updateMatrix(); // need to call it since object.matrixAutoUpdate is false
delete userData.instanceId;
delete userData.originalMatrix;
// if the cloned object is selected, then just de-select it and return
if (object === userData.clonedMesh) {
userData.clonedMesh.geometry.dispose();
delete userData.clonedMesh;
this.selectedObject = undefined;
if (this.outlinePass) {
this.outlinePass.selectedObjects = [];
}
return;
}
userData.clonedMesh.geometry.dispose();
delete userData.clonedMesh;
} else if (userData.batchId != null && userData.clonedMesh) {
this.scene && this.scene.remove(userData.clonedMesh); // clear the cloned mesh
delete userData.batchId;
// if the cloned object is selected, then just de-select it and return
if (object === userData.clonedMesh) {
userData.clonedMesh.geometry.dispose();
delete userData.clonedMesh;
this.selectedObject = undefined;
if (this.outlinePass) {
this.outlinePass.selectedObjects = [];
}
return;
}
userData.clonedMesh.geometry.dispose();
delete userData.clonedMesh;
} else if (userData.originalMaterial) {
if (this.selectedObject.material) {
// manually dispose it according to https://threejs.org/docs/#manual/en/introduction/How-to-dispose-of-objects
const material = this.selectedObject.material;
if (Array.isArray(material)) {
material.forEach((m) => m.dispose());
} else if (material instanceof THREE.Material) {
material.dispose();
}
}
this.selectedObject.material = userData.originalMaterial;
delete userData.originalMaterial; // clean up
}
this.selectedObject = undefined;
if (this.outlinePass) {
this.outlinePass.selectedObjects = [];
}
}
if (!this.scene || !object) {
this.enableRender();
return;
}
if (object instanceof THREE.InstancedMesh && instanceId != null) {
const im = object as THREE.InstancedMesh;
const originalMatrix = new THREE.Matrix4();
const hideMatrix = new THREE.Matrix4();
hideMatrix.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); // this matrix hides an object
im.getMatrixAt(instanceId, originalMatrix);
this.selectedObject = object;
if (this.outlinePass) {
this.outlinePass.selectedObjects = [object];
}
// Here is the example to select InstancedMesh, which is to call setColorAt()
// https://threejs.org/examples/?q=instanc#webgl_instancing_raycast
// While, it sounds like only support MeshPhongMaterial. So here, we'll clone
// a mesh with highlighted color to replace the original instance in InstancedMesh
const clonedMaterial = MaterialUtils.clonedHighlightMaterials(object, {
depthTest,
});
if (clonedMaterial) {
// clone a new mesh for the selected instance
const clonedMesh = new THREE.Mesh(im.geometry.clone(), clonedMaterial);
clonedMesh.applyMatrix4(object.matrixWorld.multiply(originalMatrix));
clonedMesh.matrixWorldNeedsUpdate = true;
clonedMesh.name = "Cloned mesh for highlighting";
clonedMesh.layers.enableAll();
// hide the original mesh by its matrix
const matrix = originalMatrix.clone();
matrix.multiplyMatrices(originalMatrix, hideMatrix);
im.setMatrixAt(instanceId, matrix);
im.instanceMatrix.needsUpdate = true;
im.updateMatrix(); // need to call it since object.matrixAutoUpdate is false
this.selectedObject.userData.instanceId = instanceId; // store some instanceId so highlight can be reverted
this.selectedObject.userData.originalMatrix = originalMatrix;
this.selectedObject.userData.clonedMesh = clonedMesh;
this.scene.add(clonedMesh); // add it to scene temporaly
}
} else if (MergeUtils.isMergedMesh(object as THREE.Mesh) && batchId != null) {
const batch = MergeUtils.getBatchByBatchId(object as THREE.Mesh, batchId);
let logStr = `[BimViewer] Clicked on merged mesh(id: ${object.id}).`;
logStr += ` Original mesh batchId: ${batchId}, name: ${batch?.name}`;
log.info(logStr);
const clonedMaterial = MaterialUtils.clonedHighlightMaterials(object as THREE.Mesh, { depthTest });
const clonedGeom = MergeUtils.cloneGeometryForBatch(object as THREE.Mesh, batch as Batch);
if (clonedMaterial && clonedGeom) {
const clonedMesh = new THREE.Mesh(clonedGeom, clonedMaterial);
clonedMesh.applyMatrix4(object.matrixWorld);
clonedMesh.matrixWorldNeedsUpdate = true;
clonedMesh.name = "Cloned mesh for highlighting";
clonedMesh.layers.enableAll();
this.selectedObject = object;
this.selectedObject.userData.batchId = batchId;
this.selectedObject.userData.clonedMesh = clonedMesh;
this.scene.add(clonedMesh);
}
} else {
// save the original material, so we can reverit it back when deselect
const clonedMaterial = MaterialUtils.clonedHighlightMaterials(object as THREE.Mesh, { depthTest });
if (clonedMaterial) {
this.selectedObject = object;
this.selectedObject.userData.originalMaterial = this.selectedObject.material;
this.selectedObject.material = clonedMaterial;
if (this.outlinePass) {
this.outlinePass.selectedObjects = [object];
}
}
}
this.enableRender();
}
/**
* Clears the current selection
*/
public clearSelection() {
// remove select fot drawable
if (this.selectedObject && this.selectedObject instanceof Drawable) {
this.selectedObject.selected = false;
} else {
this.selectObject(); // simply select nothing
}
this.selectedObject = undefined;
}
/**
* Gets bounding box
* @internal
*/
getBBox(): THREE.Box3 | undefined {
return this.bbox;
}
/**
* Make camera fly to objects
*/
protected flyToObjects(objects: THREE.Object3D[]) {
if (!objects || objects.length === 0 || !this.camera) {
return;
}
const eye = new THREE.Vector3();
const look = new THREE.Vector3();
const bbox = new THREE.Box3();
objects.forEach((object) => {
const box = SceneUtils.getBoundingBox(object);
bbox.union(box);
});
const cameraDir = new THREE.Vector3();
this.camera.getWorldDirection(cameraDir);
Viewer3DUtils.getCameraPositionByBboxAndDirection(bbox, eye, look, this.camera.projectionMatrix, cameraDir);
// this.flyTo(eye, look);
const distance = new THREE.Vector3().subVectors(eye, look).length();
(this.controls as CameraControlsEx).moveTo(eye.x, eye.y, eye.z, true);
if (this.camera instanceof THREE.OrthographicCamera) {
const width = this.camera.right - this.camera.left;
const height = this.camera.top - this.camera.bottom;
const bbSize = new THREE.Vector3();
bbox.getSize(bbSize);
const zoom = Math.min(width / bbSize.x, height / bbSize.y);
(this.controls as CameraControlsEx).zoomTo(zoom, true);
} else if (this.camera instanceof THREE.PerspectiveCamera) {
(this.controls as CameraControlsEx).dollyTo(distance, true);
}
(this.controls as CameraControlsEx).setFocalOffset(0, 0, 0, true);
// maybe use fitToBox better
// (this.controls as CameraControlsEx).fitToBox(bbox, true, {
// paddingTop: bbSize.length() / 5,
// paddingBottom: bbSize.length() / 5,
// paddingLeft: bbSize.length() / 5,
// paddingRight: bbSize.length() / 5,
// });
}
/**
* Make camera fly to an object
* @internal
*/
public flyToObject(object: THREE.Object3D) {
this.flyToObjects([object]);
}
/**
* Flies to current selected object if any
*/
protected flyToSelectedObject() {
if (!this.selectedObject) {
return;
}
let obj = this.selectedObject;
// if part of InstancedMesh is selected, fly to that part rather than fly to the whole InstancedMesh
if (obj instanceof THREE.InstancedMesh && obj.userData.clonedMesh) {
obj = obj.userData.clonedMesh;
} else if (MergeUtils.isMergedMesh(obj) && obj.userData.clonedMesh) {
obj = obj.userData.clonedMesh;
}
this.flyToObject(obj);
}
/**
* Flies to a random object (by alt + r).
* It is useful when either the data is wrong or there is bug in program,
* then we cannot see anything in the scene!
*/
protected flyToRandomObject() {
const modelIds = Object.values(this.loadedModels).map((obj) => obj.id);
Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id));
const modelCount = modelIds.length;
if (modelCount <= 0) {
return;
}
const index = Math.floor(Math.random() * modelCount);
const id = modelIds[index];
const model = this.scene?.getObjectById(id);
if (!model) {
return;
}
const objectIds: number[] = []; // store id, rather than id or object instances
this.scene?.traverseVisible((obj: THREE.Object3D) => {
const objectNamesToExclude = ["SKYBOX", "GROUND_GRID", "GRASS_GROUND", "BIM_VIEWER_BOX_HELPER"];
if ((obj instanceof THREE.Mesh || obj instanceof THREE.Line) && !objectNamesToExclude.includes(obj.name)) {
objectIds.push(obj.id);
}
});
if (objectIds.length < 1) {
return;
}
const rand = Math.floor(Math.random() * objectIds.length);
const obj = this.scene?.getObjectById(objectIds[rand]);
if (!obj) {
return;
}
log.info(`[BimViewer] Flying to random object: ${obj.name}, type: ${obj.type}`);
this.selectObject(obj, undefined, undefined, true);
this.flyToObject(obj);
}
/**
* Make camera fly to target position with given lookAt position
* @param position camera's target position
* @param lookAt camera's new lookAt position
*/
public flyTo(position: THREE.Vector3, lookAt: THREE.Vector3) {
// TODO: redesign
const camera = this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera;
const controls = this.controls as CameraControlsEx;
if (!camera || !controls) {
return;
}
if (position.equals(lookAt)) {
log.error("[BimViewer] camera position and lookAt cannot be the same!");
return;
} else if (!CommonUtils.isVectorValid(position) || !CommonUtils.isVectorValid(lookAt)) {
log.error("[BimViewer] invalid position or lookAt!");
return;
}
// If distance between position and lookAt is too near or far (according to camera's near/far settings).
// need to adjust 'position' to fit it.
const distance = position.distanceTo(lookAt);
if (distance < camera.near) {
// the new position is just farer than original position
position = position
.clone()
.sub(lookAt)
.normalize()
.multiplyScalar(camera.near * 1.1);
log.warn("[BimViewer] camera could be too close to see the object!");
} else if (distance > camera.far) {
// the new position is just closer than original position
position = position
.clone()
.sub(lookAt)
.normalize()
.multiplyScalar(camera.far * 0.9);
log.warn("[BimViewer] camera could be too far to see the object!");
}
// It seem that setOrbitPoint and setLookAt can not use in the same
controls.setLookAt(position.x, position.y, position.z, lookAt.x, lookAt.y, lookAt.z, true);
// const update = (p: THREE.Vector3, t: THREE.Vector3) => {
// controls.setLookAt(p.x, p.y, p.z, t.x, t.y, t.z);
// };
// // since we've implemented a new controls, whose target may not be at the center
// // of screen, so, we'd better use camera's position. And, let's assume the origin target
// // is in the center of screen.
// const originPosition = camera.position.clone();
// const cameraDir = new THREE.Vector3();
// camera.getWorldDirection(cameraDir);
// const dist = controls.getTarget(new THREE.Vector3()).distanceTo(originPosition);
// // calculate a target in the center of screen
// const originLookAt = originPosition.clone().addScaledVector(cameraDir, dist);
// const totalTime = 500; // total duration in ms
// const startTime = Date.now();
// this.cameraUpdateInterval && clearInterval(this.cameraUpdateInterval);
// this.cameraUpdateInterval = setInterval(() => {
// let elipsedTime = Date.now() - startTime;
// if (elipsedTime > totalTime) {
// elipsedTime = totalTime;
// }
// // calculate current position by alpha
// const calcCurrPos = (v1: THREE.Vector3, v2: THREE.Vector3, alpha: number): THREE.Vector3 => {
// const x = v1.x + (v2.x - v1.x) * alpha;
// const y = v1.y + (v2.y - v1.y) * alpha;
// const z = v1.z + (v2.z - v1.z) * alpha;
// return new THREE.Vector3(x, y, z);
// };
// const p = calcCurrPos(originPosition, position, elipsedTime / totalTime);
// const t = calcCurrPos(originLookAt, lookAt, elipsedTime / totalTime);
// update(p, t);
// if (elipsedTime >= totalTime) {
// clearInterval(this.cameraUpdateInterval);
// this.cameraUpdateInterval = undefined;
// }
// }, 10);
}
/**
* Fits the camera to view all objects in scene
*/
public viewFitAll() {
if (!this.scene || !this.camera) {
return;
}
const eye = new THREE.Vector3();
const look = new THREE.Vector3();
const bbox = SceneUtils.getVisibleObjectBoundingBox(this.scene);
const cameraDir = new THREE.Vector3();
this.camera?.getWorldDirection(cameraDir);
Viewer3DUtils.getCameraPositionByBboxAndDirection(bbox, eye, look, this.camera.projectionMatrix, cameraDir);
this.flyTo(eye, look);
}
/**
* Goes to home view
*/
public goToHomeView() {
const camera = this.camera;
const home = this.cameraCfg;
const position = home && CommonUtils.arrayToVector3(home.eye);
const target = home && CommonUtils.arrayToVector3(home.look);
if (position && target) {
// TODO: flyTo() doesn't handle OrthographicCamera properly, we need another process!
// this.flyTo(position, target);
(this.controls as CameraControlsEx).setLookAt(
position.x,
position.y,
position.z,
target.x,
target.y,
target.z,
true
);
(this.controls as CameraControlsEx).setFocalOffset(0, 0, 0, true);
} else if (this.scene) {
// if home.eye not defined in project, then go to 'Front'
const eye = new THREE.Vector3();
const look = new THREE.Vector3();
const cameraDir = new THREE.Vector3(-1, -0.5, -1);
if (this.has2dModel) {
// get a proper direction by bbox
const sizeX = this.bbox.max.x - this.bbox.min.x;
const sizeY = this.bbox.max.y - this.bbox.min.y;
const sizeZ = this.bbox.max.z - this.bbox.min.z;
const minSize = Math.min(sizeX, sizeY, sizeZ);
if (sizeX - minSize <= 0) {
cameraDir.set(1, 0, 0); // left view
} else if (sizeY - minSize <= 0) {
cameraDir.set(0, -1, 0); // top view
} else if (sizeZ - minSize <= 0) {
cameraDir.set(0, 0, -1); // front view
}
}
Viewer3DUtils.getCameraPositionByBboxAndDirection(this.bbox, eye, look, camera?.projectionMatrix, cameraDir);
if (!this.cameraCfg || (this.cameraCfg && (!this.cameraCfg.eye || !this.cameraCfg.look))) {
this.cameraCfg = {
eye: eye.toArray(),
look: look.toArray(),
};
}
if (camera instanceof THREE.OrthographicCamera) {
const bbox = this.bbox;
const sizeX = bbox.max.x - bbox.min.x;
const sizeZ = bbox.max.z - bbox.min.z;
// adjust zoom value according to object size and camera's top/bottom/left/right
const leftRightSize = camera.right - camera.left;
const bottomTopSize = camera.top - camera.bottom;
camera.zoom = Math.max(leftRightSize, bottomTopSize) / Math.max(sizeX, sizeZ);
camera.zoom /= 2; // make it smaller seems better
camera.updateProjectionMatrix();
}
// do not allow camera's target and position is the same point!!
if (!eye.equals(look)) {
this.flyTo(eye, look);
}
}
}
public zoomToBBox(bbox: THREE.Box3) {
const eye = new THREE.Vector3();
const look = new THREE.Vector3();
const cameraDir = new THREE.Vector3();
this.camera?.getWorldDirection(cameraDir);
Viewer3DUtils.getCameraPositionByBboxAndDirection(bbox, eye, look, this.camera?.projectionMatrix, cameraDir);
if (!eye.equals(look)) {
// this.flyTo(eye, look);
const distance = new THREE.Vector3().subVectors(eye, look).length();
(this.controls as CameraControlsEx).moveTo(eye.x, eye.y, eye.z, true);
if (this.camera instanceof THREE.OrthographicCamera) {
const width = this.camera.right - this.camera.left;
const height = this.camera.top - this.camera.bottom;
const bbSize = new THREE.Vector3();
bbox.getSize(bbSize);
const zoom = Math.min(width / bbSize.x, height / bbSize.y);
(this.controls as CameraControlsEx).zoomTo(zoom, true);
} else if (this.camera instanceof THREE.PerspectiveCamera) {
(this.controls as CameraControlsEx).dollyTo(distance, true);
}
(this.controls as CameraControlsEx).setFocalOffset(0, 0, 0, true);
}
}
/**
* Tries to adjust camera near/far clip plane according to objects size in scene.
* Do this to avoid the case when objects are too small or big thus clipped!
*/
private tryAdjustCameraNearAndFar() {
const camera = this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera;
if (!this.scene || !camera) {
return;
}
const bbox = this.bbox;
const near = camera.near;
const far = camera.far;
const sizeX = bbox.max.x - bbox.min.x;
const sizeY = bbox.max.y - bbox.min.y;
const sizeZ = bbox.max.z - bbox.min.z;
const maxSize = Math.max(sizeX, sizeY, sizeZ);
const factor = 5; // a value according to experience
const maxNear = maxSize / factor; // camera.near shouldn't bigger than this
const minFar = maxSize * factor; // camera.far shouldn't smaller than this
if (near > maxNear || far < minFar) {
const n2s = (n: number): string => CommonUtils.numberToString(n);
log.info(`[BimViewer] BBox's longest side is: ${n2s(maxSize)}`);
if (near > maxNear) {
log.warn(`[BimViewer] camera.near(${n2s(near)}) shouldn't bigger than ${n2s(maxNear)}, will change it!`);
camera.near = maxNear;
}
if (far < minFar) {
log.warn(`[BimViewer] camera.far(${n2s(far)}) shouldn't smaller than ${n2s(minFar)}, will change it!`);
camera.far = minFar;
}
}
// adjust zoom value according to object size and camera's top/bottom/left/right
if (camera instanceof THREE.OrthographicCamera) {
const leftRightSize = camera.right - camera.left;
const bottomTopSize = camera.top - camera.bottom;
camera.zoom = Math.max(leftRightSize, bottomTopSize) / Math.max(sizeX, sizeZ);
camera.zoom /= 2; // make it smaller seems better
}
camera.updateProjectionMatrix();
}
// TODO: Set the fixed direction according to the time
private tryAdjustDirectionalLight() {
if (!this.directionalLight) {
return;
}
const bbox = this.bbox;
const sphere = new THREE.Sphere();
bbox.getBoundingSphere(sphere);
const center = sphere.center;
const radius = sphere.radius;
const camera = this.directionalLight.shadow.camera;
camera.zoom = 1.0;
camera.top = radius;
camera.bottom = -radius;
camera.right = radius;
camera.left = -radius;
camera.near = 0.1;
camera.far = 2 * radius;
camera.updateProjectionMatrix();
const lightDirection = new THREE.Vector3(-1, -0.5, 1);
// Keep the default direction consistent with UE
//const lightDirection = new THREE.Vector3(0.641, -0.423, -0.641);
lightDirection.normalize().multiplyScalar(radius);
this.directionalLight.position.copy(center).addScaledVector(lightDirection, -1);
this.directionalLight.target.position.copy(center);
log.debug("[BimViewer] this.directionalLight.position", this.directionalLight.position);
log.debug("[BimViewer] this.directionalLight.target.position", this.directionalLight.target.position);
this.updateDirectionalLight();
this.enableRender();
}
/**
* @internal
*/
updateDirectionalLight() {
if (!this.directionalLight) {
return;
}
this.directionalLight.updateMatrixWorld();
this.directionalLight.target.updateMatrixWorld();
if (this.directionalLightHelper) {
// matrix = light.matrixWorld
this.directionalLightHelper.update();
!this.scene?.matrixAutoUpdate && this.directionalLightHelper.updateWorldMatrix(false, true);
}
this.directionalLight.shadow.needsUpdate = true;
// update the light's shadow camera's projection matrix
this.directionalLight.shadow.camera.updateProjectionMatrix();
if (this.shadowCameraHelper) {
// and now update the camera helper we're using to show the light's shadow camera
this.shadowCameraHelper.update();
if (!this.scene?.matrixAutoUpdate) {
const shadowCamera = this.directionalLight.shadow.camera;
shadowCamera.position.setFromMatrixPosition(this.directionalLight.matrixWorld);
tempVec3.setFromMatrixPosition(this.directionalLight.target.matrixWorld);
shadowCamera.lookAt(tempVec3);
shadowCamera.updateMatrixWorld();
// matrix = camera.worldMatrix
this.shadowCameraHelper.updateWorldMatrix(false, true);
}
}
}
private updateDirectionalLightShadow() {
if (!this.directionalLight) {
return;
}
this.directionalLight.shadow.needsUpdate = true;
}
/**
* @internal
*/
showDirectionalLightHelper(visible: boolean) {
if (this.directionalLightHelper) {
this.directionalLightHelper.visible = visible;
}
if (this.shadowCameraHelper) {
this.shadowCameraHelper.visible = visible;
}
}
/**
* Regenerates skybox according to models' location and size
*/
private regenSkyOfGradientRamp() {
if (!this.scene) {
return;
}
if (this.skyOfGradientRamp) {
this.skyOfGradientRamp.geometry.dispose();
(this.skyOfGradientRamp.material as THREE.Material).dispose();
this.scene.remove(this.skyOfGradientRamp);
this.skyOfGradientRamp.clear();
this.skyOfGradientRamp = undefined;
}
const modelIds = Object.values(this.loadedModels).map((obj) => obj.id);
Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id));
if (modelIds) {
const home = this.cameraCfg;
const position = home && CommonUtils.arrayToVector3(home.eye);
const target = home && CommonUtils.arrayToVector3(home.look);
let bbox = new THREE.Box3();
if (position && target) {
const p1 = position;
const p2 = target;
bbox.expandByPoint(new THREE.Vector3(p1.x, p1.y, p1.z));
bbox.expandByPoint(new THREE.Vector3(p2.x, p2.y, p2.z));
} else {
bbox = SceneUtils.getObjectsBoundingBox(this.scene, modelIds);
}
this.skyOfGradientRamp = SkyboxUtils.createSkyOfGradientRampByBoundingBox(bbox);
this.scene.add(this.skyOfGradientRamp);
}
}
/**
* Regenerates ground grid according to models' location and size
*/
private regenGroundGrid() {
if (!this.scene) {
return;
}
if (this.groundGrid) {
this.groundGrid.geometry.dispose();
(this.groundGrid.material as THREE.Material).dispose();
this.scene.remove(this.groundGrid);
}
const modelIds = Object.values(this.loadedModels).map((obj) => obj.id);
Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id));
if (modelIds) {
const home = this.cameraCfg;
const center = home && CommonUtils.arrayToVector3(home.look);
center && (center.y = 0);
// TODO: will need to consider ground size according to models' size
this.groundGrid = GroundUtils.createGroundGrid(undefined, undefined, center);
this.scene.add(this.groundGrid);
}
}
/**** Anchor rotation related interface start ****/
private setOrbitPoint(event: EventInfo) {
const controls = this.controls as CameraControlsEx;
if (!this.camera || !this.renderer || !this.controls || !this.scene || !this.raycaster || !this.viewerContainer) {
return;
}
if (this.selectedObject && this.selectedObject instanceof THREE.Object3D) {
const box = new THREE.Box3().setFromObject(this.selectedObject);
const center = new THREE.Vector3();
box.getCenter(center);
controls?.setOrbitPoint(center.x, center.y, center.z);
const point = CoordinateConversionUtils.worldPosition2ScreenPoint(center, this.camera, this.viewerContainer);
this.setAnchorPosition(point);
} else {
this.raycaster && this.raycaster.layers.set(layerForHitableObjects);
const intersections = this.getIntersections(event);
const vector = new THREE.Vector2(event.x, event.y);
if (intersections && intersections.length !== 0) {
const point = intersections[0].point;
controls?.setOrbitPoint(point.x, point.y, point.z);
this.setAnchorPosition(vector);
} else {
const center = this.bbox.getCenter(new THREE.Vector3());
controls.setOrbitPoint(center.x, center.y, center.z);
const screenPosition = CoordinateConversionUtils.worldPosition2ScreenPoint(
center,
this.camera,
this.renderer.domElement
);
this.setAnchorPosition(screenPosition);
}
}
}
private onAnchorPointerDown = (event: EventInfo) => {
if (!this.controls?.enableRotate || !this.renderer || this.sectionManager?.isSectionActive()) {
return;
}
this.setOrbitPoint(event);
};
private setAnchorPosition(position: THREE.Vector2) {
if (this.anchor) {
this.anchor.className = `anchor active`;
this.anchor.style.left = `${position.x}px`;
this.anchor.style.top = `${position.y}px`;
}
}
private createAnchor() {
const anchor = document.createElement("div");
anchor.className = "anchor";
this.viewerContainer?.appendChild(anchor);
return anchor;
}
private disposeAnchor() {
if (this.anchor) {
this.viewerContainer?.removeChild(this.anchor);
this.anchor = undefined;
}
}
private onAnchorPointerUp = () => {
if (this.anchor) {
this.anchor.className = `anchor`;
}
if (!this.viewerContainer || !this.camera || !this.controls) {
return;
}
};
private disposeRotateToCursor() {
this.disposeAnchor();
}
/******* Anchor rotation related interface end *********/
/**
* Adds a hotpoint.
* Caller should set a hotpointId that is unique in the session of current DxfViewer.
*/
addHotpoint(hotpoint: Hotpoint) {
const exists = this.hasHotpoint(hotpoint.hotpointId);
if (exists) {
log.warn(`[BimViewer] Hotpoint with id '${hotpoint.hotpointId}' already exist!`);
return;
}
const p = hotpoint.anchorPosition;
const object = CSS2DObjectUtils.createHotpoint(hotpoint.html);
object.position.set(p[0] || 0, p[1] || 0, p[2] || 0);
object.visible = hotpoint.visible !== false;
object.userData.hotpoint = hotpoint;
if (!this.hotpointRoot) {
this.hotpointRoot = new THREE.Group();
this.hotpointRoot.matrixAutoUpdate = matrixAutoUpdate;
this.hotpointRoot.matrixWorldAutoUpdate = false;
this.hotpointRoot.name = "HotpointRoot";
this.scene?.add(this.hotpointRoot);
}
this.hotpointRoot.add(object);
object.updateWorldMatrix(false, false);
this.enableRender();
}
/**
* Removes a hotpoint by given hotpointId.
* Caller should set a hotpointId that is unique in the session of current DxfViewer.
*/
removeHotpoint(hotpointId: string) {
const objects = this.hotpointRoot?.children || [];
for (let i = 0; i < objects.length; ++i) {
const obj = objects[i];
if (obj.userData.hotpoint?.hotpointId === hotpointId) {
obj.removeFromParent();
}
}
}
/**
* Clears all hotpoints.
*/
clearHotpoints() {
this.hotpointRoot?.clear();
}
/**
* Checks if hotpoint with specific id already exist
* Caller should set a hotpointId that is unique in the session of current DxfViewer.
* @internal
*/
hasHotpoint(hotpointId: string): boolean {
const objects = this.hotpointRoot?.children || [];
return objects.findIndex((obj) => obj.userData.hotpoint?.hotpointId === hotpointId) !== -1;
}
/**
* Enables or disable Composer
* @internal
*/
public enableComposer(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer) {
return;
}
this.composerEnabled = enable;
if (enable && !this.composer) {
this.composer = new EffectComposer(this.renderer);
}
this.enableRender();
}
/**
* Enables or disable RenderPass
* @internal
*/
public enableRenderPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.renderPass) {
const pass = new RenderPass(this.scene, this.camera);
pass.setSize(this.width, this.height);
this.composer.addPass(pass);
this.renderPass = pass;
}
if (this.renderPass) {
this.renderPass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable FxaaPass
* @internal
*/
public enableFxaaPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.effectFxaaPass) {
const pass = new ShaderPass(FXAAShader);
// eslint-disable-next-line
pass.uniforms["resolution"].value.set(1 / this.width, 1 / this.height);
pass.setSize(this.width, this.height);
pass.renderToScreen = true;
this.composer.addPass(pass);
this.effectFxaaPass = pass;
}
if (this.effectFxaaPass) {
this.effectFxaaPass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable SAOPass
* @internal
*/
public enableSaoPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.saoPass) {
const pass = new SAOPass(this.scene, this.camera, false, true, new THREE.Vector2(1 / this.width, 1 / this.height));
pass.setSize(this.width, this.height);
// pass.renderToScreen = true
// pass.resolution.set(1024, 1024)
pass.params.output = 0;
pass.params.saoBias = 0.5; // -1 - 1
pass.params.saoIntensity = 0.00005; // 0 - 1
pass.params.saoScale = 5; // 0 - 10
pass.params.saoKernelRadius = 40; // 1 - 100
pass.params.saoMinResolution = 0; // 0 - 1
// pass.params.saoBlur = true
// pass.params.saoBlurRadius = 8 // 0 - 200
// pass.params.saoBlurStdDev = 4 // 0.5 - 150
// pass.params.saoBlurDepthCutoff = 0.01 // 0 - 0.1
this.composer.addPass(pass);
this.saoPass = pass;
}
if (this.saoPass) {
this.saoPass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable SSAOPass
* @internal
*/
public enableSsaoPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.ssaoPass) {
const pass = new SSAOPass(this.scene, this.camera, this.width, this.height);
pass.kernelRadius = 16;
pass.minDistance = 0.005; // 0.001 - 0.02
pass.maxDistance = 0.1; // 0.01 - 0.3
// pass.output = 0 // 'Default': 0, 'SSAO': 1, 'Blur': 2, 'Beauty': 3, 'Depth': 4, 'Normal': 5
this.composer.addPass(pass);
this.ssaoPass = pass;
}
if (this.ssaoPass) {
this.ssaoPass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable OutlinePass
* @internal
*/
public enableOutlinePass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.outlinePass) {
const pass = new OutlinePass(new THREE.Vector2(this.width, this.height), this.scene, this.camera);
pass.edgeStrength = 3;
pass.edgeGlow = 0;
pass.edgeThickness = 2;
pass.pulsePeriod = 0; // 0: don't pulse
// outlinePass.usePatternTexture =
pass.visibleEdgeColor.set(0xff0000);
pass.hiddenEdgeColor.set(0xffa080);
this.composer.addPass(pass);
this.outlinePass = pass;
}
if (this.outlinePass) {
this.outlinePass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable SSAARenderPass
* @internal
*/
public enableSsaaPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.ssaaRenderPass) {
const pass = new SSAARenderPass(this.scene, this.camera, 0xffffff, 0);
this.composer.addPass(pass);
this.ssaaRenderPass = pass;
}
if (this.ssaaRenderPass) {
this.ssaaRenderPass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable BloomPass
* @internal
*/
public enableBloomPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.bloomPass) {
const pass = new BloomPass(
1, // strength
25, // kernel size
4 // sigma ?
);
pass.renderToScreen = true; // usually set it to true if it is the last pass
this.composer.addPass(pass);
this.bloomPass = pass;
}
if (this.bloomPass) {
this.bloomPass.enabled = enable;
}
this.enableRender();
}
/**
* Enables or disable UnrealBloomPass
* @internal
*/
public enableUnrealBloomPass(enable: boolean) {
if (!this.scene || !this.camera || !this.renderer || !this.composer) {
return;
}
if (enable && !this.unrealBloomPass) {
const pass = new UnrealBloomPass(new THREE.Vector2(this.width, this.height), 1, 0, 0);
pass.threshold = 0;
pass.strength = 0.5;
pass.radius = 0;
this.composer.addPass(pass);
this.unrealBloomPass = pass;
}
if (this.unrealBloomPass) {
this.unrealBloomPass.enabled = enable;
}
this.enableRender();
}
/**
* Enable section.
* Currently, it only implemented local(object) box section.
*/
public activateSection(type = SectionType.ObjectsBoxSection, clippingObjectIds?: number[]) {
if (!this.inputManager || this.sectionManager?.isSectionActive()) {
return;
}
this.sectionType = type;
this.clearSelection();
if (!this.sectionManager) {
this.sectionManager = new SectionManager(this, this.inputManager);
}
this.sectionManager.activateSection(type, clippingObjectIds);
this.enableRender();
}
/**
* Deactivates section
*/
public deactivateSection() {
this.sectionManager?.deactivateSection();
this.enableRender();
}
/**
* @internal
*/
setSectionClippingObjectIds(clippingObjectIds?: number[]) {
this.sectionManager?.setSectionClippingObjectIds(clippingObjectIds);
this.enableRender();
}
/**
* @internal
*/
getActiveSection() {
return this.sectionManager?.getActiveSection();
}
/**
* @internal
*/
getMeasurementManager() {
return this.measurementManager;
}
/**
* Gets measurement data
* @internal
*/
getMeasurements(): MeasurementData[] {
if (!this.inputManager) {
return [];
}
if (!this.measurementManager) {
this.measurementManager = new MeasurementManager(this, this.inputManager);
}
const mm = this.measurementManager;
return mm.getMeasurementsData();
}
/**
* Activates one of "Distance", "Area" or "Angle" measurement
* @param type "Distance", "Area" or "Angle"
*/
activateMeasurement(type: MeasurementType) {
if (!this.inputManager) {
return;
}
if (!this.measurementManager) {
this.measurementManager = new MeasurementManager(this, this.inputManager);
}
const mm = this.measurementManager;
mm.activateMeasurement(type);
this.clearSelection();
}
/**
* Deactivates measurement
*/
deactivateMeasurement() {
this.measurementManager?.deactivateMeasurement();
}
/**
* @internal
*/
setMeasurementVisibility(id: string, visible: boolean): boolean {
if (!this.inputManager || !this.measurementManager || !id) {
return false;
}
const mm = this.measurementManager;
return mm.setMeasurementVisibility(id, visible);
}
/**
* Clears all measurement results
*/
clearMeasurements() {
this.measurementManager?.clearMeasurements();
}
/**
* Zooms to selected box area.
*/
zoomToRect() {
if (!this.zoomToRectHelper) {
this.zoomToRectHelper = new ZoomToRectHelper(this);
}
this.zoomToRectHelper.activate();
}
/**
* @internal
*/
deactivateZoomRect() {
this.zoomToRectHelper?.deactivate();
}
/**
* @internal
*/
public enableWebCam() {
if (!this.scene) {
return;
}
// for now, this is a demo. Just create a 5x4 plane and put it somewhere
if (!this.webcam) {
this.webcam = new WebCam();
}
if (!this.webcamPlane) {
this.webcamPlane = this.webcam.createWebCamPlane();
this.webcamPlane.position.set(10, 2, 0);
}
this.scene.add(this.webcamPlane);
}
/**
* @internal
*/
public disableWebCam() {
if (!this.scene) {
return;
}
if (this.webcamPlane) {
this.webcamPlane.geometry.dispose();
(this.webcamPlane.material as THREE.Material).dispose();
this.scene.remove(this.webcamPlane);
}
}
/**
* Sets environment for the scene.
* @param hdrUrl Full path of picture url in hdr format
*/
public setEnvironment(hdrUrl: string) {
TextureUtils.createEnvTexture(this.pmremGenerator, hdrUrl).then((texture) => {
if (this.scene) {
this.scene.environment = texture;
}
});
}
/**
* Sets environment for the scene.
* @param data Uint16Array of the hdr content
* @internal
*/
public setEnvironmentFromDataArray(data?: Uint16Array) {
TextureUtils.createEnvTextureFromDataArray(this.pmremGenerator, data).then((texture) => {
if (this.scene) {
this.scene.environment = texture;
}
});
}
async takeObjectsScreenshot(uniqueIds: string[]) {
return new Promise((resolve, reject) => {
if (!this.renderer) {
reject("renderer is undefined");
}
// const excludeObjectIds: string[] = [];
this.scene?.traverse((object: THREE.Object3D) => {
if (object instanceof THREE.Mesh) {
if (includes(uniqueIds, get(object.userData, "UniqueId"))) {
// excludeObjectIds.push(object.id);
object.visible = true;
} else {
object.visible = false;
}
}
});
// ObjectUtils.setObjectOpacity(this.scene, 0.1, undefined, excludeObjectIds);
this.enableRender();
setTimeout(() => {
resolve(this.renderer?.domElement.toDataURL("image/png"));
}, 1000);
});
}
/**
* Sets object to a specific color. Note that:
* - The change is permanent, and cannot be recovered to the original color or material.
* - If a material is shared, it may affect other objects.
* @param color A color number in format of "0x000000"
* @internal
*/
setObjectColor(object: THREE.Object3D, color: number) {
// unselect any selected/highlighted object, otherwise, there maybe bug
this.clearSelection();
// Find out all materials for the object. The key is material id.
const materials: Record<number, THREE.Material> = {};
object.traverse((obj) => {
const material = (obj as any).material; // eslint-disable-line
if (!material) {
return;
}
if (Array.isArray(material)) {
material.forEach((mat: THREE.Material) => {
materials[mat.id] = mat;
});
} else {
materials[material.id] = material;
}
});
// change color one by one
for (const id in materials) {
MaterialUtils.setMaterialColor(materials[id], new THREE.Color(color));
}
this.enableRender();
}
/**
* Updates raycaster threshold to a proper value, so user can easily pick points and lines
*/
private updateRaycasterThreshold() {
const camera = this.camera;
if (!camera || !this.raycaster) {
return;
}
// TODO: There are problems about line raycaster in OrthographicCamera.
// Zoom need to be considered.
const threshold = 12 / camera.zoom;
const params = this.raycaster.params;
if (!params.Line) {
params.Line = { threshold: threshold };
} else {
params.Line.threshold = threshold;
}
if (!params.Points) {
params.Points = { threshold: threshold };
} else {
params.Points.threshold = threshold;
}
// log.info(`[BimViewer] Raycaster threshold: ${threshold}`);
}
/**
* Instatiates leaf nodes of given object.
* If objects' geometry and material are the same, they can be instanced.
* @param object
*/
private instantiate(object: THREE.Object3D) {
new InstantiateHelper(object).instantiate();
}
/**
* Merges leaf nodes of given object.
* If objects' materials are the same, they can be merged.
* @param object
*/
private merge(object: THREE.Object3D) {
this.increaseJobCount();
try {
const objects: THREE.Object3D[] = [];
object.traverse((obj) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((obj as any).geometry && (obj as any).material) {
// eslint-disable-line
objects.push(obj);
}
});
MergeUtils.deepMerge(objects, object);
} finally {
this.decreaseJobCount();
}
}
/**
* Updates project settings
* @internal
*/
public updateProjectSettings(settings: SettingsType) {
this.settings = settings;
const updateCameraSettings = (
c: THREE.PerspectiveCamera | THREE.OrthographicCamera | undefined,
cs: CameraSettings
) => {
if (c && cs) {
c.near = cs.near;
c.far = cs.far;
c.updateProjectionMatrix();
}
};
updateCameraSettings(this.perspectiveCamera, this.settings.camera);
updateCameraSettings(this.orthoCamera, this.settings.camera);
this.enableRender(10);
}
/**
* Compute bounding box of loaded models
* @internal
*/
public computeBoundingBox(): THREE.Box3 {
const bbox = new THREE.Box3();
Object.values(this.loadedModels).forEach((model: { id: number; bbox?: THREE.Box3 }) => {
if (model.bbox && !model.bbox.isEmpty()) {
bbox.union(model.bbox);
}
});
Object.values(this.loaded3dTiles).forEach((model: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => {
if (!model.bbox.isEmpty()) {
bbox.union(model.bbox);
}
});
this.bbox = bbox;
if (this.controls) {
this.controls.minDistance = 0.1;
this.controls.maxDistance = this.bbox.getSize(new THREE.Vector3()).length() * 3;
}
return bbox;
}
}