Files
gemini-viewer-examples/sdk-src/viewers/BimViewer.ts
2023-06-27 13:18:15 +08:00

3058 lines
118 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}