import * as THREE from 'three'; import { Object3D, Quaternion, Vector3, WebGLRenderer } from 'three'; import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'; import { SVGRenderer } from 'three/examples/jsm/renderers/SVGRenderer'; import { AnimationStyle, Control, defaults, ExportType, Renderer, ThreePosition } from './constants'; import { TooltipHelper } from './tooltip-helper'; import { InsetHelper, ScenePosition } from './inset-helper'; import { getSceneWithBackground, ThreeBuilder } from './three_builder'; import { DebugHelper } from './debug-helper'; import { disposeSceneHierarchy, getScreenCoordinate, getThreeScreenCoordinate, moveAndUnprojectPoint, ObjectRegistry } from '../utils'; // @ts-ignore //import img from './glass.png'; import { OutlineEffect } from 'three/examples/jsm/effects/OutlineEffect'; import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls'; import { SceneJsonObject } from './simple-scene'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; import { AnimationHelper } from './animation-helper'; import '../CrystalToolkitScene/CrystalToolkitScene.less'; import { CameraState } from '../CameraContextProvider/camera-reducer'; const POINTER_CLASS = 'show-pointer'; export default class Scene { private settings; private renderer!: THREE.WebGLRenderer | SVGRenderer; private labelRenderer!: CSS2DRenderer; public scene!: THREE.Scene; // expose getter instead private cachedMountNodeSize!: { width: number; height: number }; private camera!: THREE.OrthographicCamera; private cameraState?: CameraState; private frameId?: number; private clickableObjects: THREE.Object3D[] = []; private tooltipObjects: THREE.Object3D[] = []; private objectDictionnary: { [id: string]: any } = {}; private controls; private tooltipHelper = new TooltipHelper(); private axis!: Object3D; private axisJson: any; private inset!: InsetHelper; private inletPosition!: ScenePosition; private objectBuilder: ThreeBuilder; private clickCallback: (objects: any[]) => void; private debugHelper!: DebugHelper; private readonly raycaster = new THREE.Raycaster(); private outline!: OutlineEffect; private selectedJsonObjects: any[] = []; private outlineScene = new THREE.Scene(); private threeUUIDTojsonObject: { [uuid: string]: any } = {}; private computeIdToThree: { [id: string]: THREE.Object3D } = {}; // handle multiSelection via shift key private isMultiSelectionEnabled = false; private registry = new ObjectRegistry(); private clock = new THREE.Clock(); private animationHelper: AnimationHelper; private tiling: any; private maxTiling: any; private arrayOfTileRoots: any; private cacheMountBBox(mountNode: Element) { this.cachedMountNodeSize = { width: mountNode.clientWidth, height: mountNode.clientHeight }; } private determineSceneRenderer() { switch (this.settings.renderer) { case Renderer.WEBGL: { const renderer = new THREE.WebGLRenderer({ antialias: this.settings.antialias, alpha: this.settings.transparentBackground }); renderer.autoClear = false; renderer.setPixelRatio(window.devicePixelRatio); renderer.gammaFactor = 2.2; renderer.setClearColor(0xfffff, 0.0); return renderer; } case Renderer.SVG: { return new SVGRenderer(); } default: { console.error('Invalid renderer passed', this.settings.renderer); return null; } } } private configureSceneRenderer(mountNode: Element) { const renderer = this.determineSceneRenderer(); if (!renderer) { throw new Error('No renderer'); } this.renderer = renderer; this.renderer.setSize(this.cachedMountNodeSize.width, this.cachedMountNodeSize.height); //TODO(chab) This should be simpler mountNode.appendChild(this.renderer.domElement); } /* this function returns a 3-dimensional empty array based on the tiling array e.g. _getTiles([1, 1, 1] === [ [ [[], []], [[], []] ], [ [[], []], [[], []] ] ] all of the arrays are unique instances, not copies this allows us to create an array that can store the contents of each tiles and be accessed with the tile indices. For example: arr = _getTiles([2, 2, 2]) arr[0][1][2].push(scene) scene = arr[0][1][2][0] */ private static getEmptyTilesArray(tiling: number[]) { let grid = []; for (let x = 0; x <= tiling[2]; x++) { let arrX = []; for (let y = 0; y <= tiling[1]; y++) { let arrY = []; for (let z = 0; z <= tiling[0]; z++) { let arrZ = []; // @ts-ignore arrY.push(arrZ); } // @ts-ignore arrX.push(arrY); } // @ts-ignore grid.push(arrX); } return grid; } private configureLabelRenderer(mountNode: Element) { const labelRenderer = new CSS2DRenderer(); this.labelRenderer = labelRenderer; const width = mountNode.clientWidth; const height = mountNode.clientHeight; labelRenderer.setSize(width, height); labelRenderer.domElement.style.position = 'relative'; labelRenderer.domElement.style.top = `-${height}px`; labelRenderer.domElement.style.pointerEvents = 'none'; mountNode.appendChild(labelRenderer.domElement); } mouseMoveListener = (e) => { if (this.renderer instanceof WebGLRenderer || true) { // tooltips let p = this.getClickedReference(e.offsetX, e.offsetY, this.tooltipObjects); if (p && p.object) { const { object, point } = p; this.tooltipHelper.updateTooltip(point, object!.jsonObject, object!.sceneObject); this.renderScene(); } else { this.tooltipHelper.hideTooltipIfNeeded() && this.renderScene(); } // change mouse pointer for clickable objects p = this.getClickedReference(e.offsetX, e.offsetY, this.clickableObjects); if (p && p.object) { this.renderer.domElement.classList.add(POINTER_CLASS); } else { this.renderer.domElement.classList.remove(POINTER_CLASS); } } else { console.warn('No mousemove implementation for SVG'); } }; clickListener = (e) => { if (this.renderer instanceof WebGLRenderer || true) { const p = this.getClickedReference(e.offsetX, e.offsetY, this.clickableObjects); this.onClickImplementation(p, e); } else { console.warn('No implementation of click for SVG'); } }; private configureScene() { this.scene = getSceneWithBackground(this.settings); this.clickableObjects = []; this.objectDictionnary = {}; // default camera this.camera = new THREE.OrthographicCamera(100, 100, 100, 100, 100); const lights = this.objectBuilder.makeLights(this.settings.lights); this.scene.add(lights); this.scene.add(this.tooltipHelper.tooltip); this.scene.add(this.camera); this.renderer.domElement.addEventListener('mousemove', this.mouseMoveListener); this.renderer.domElement.addEventListener('click', this.clickListener); // when the component is mounted, the camera can be updated in the same event loop // if the scene is configured // we defer the initialization of the control to the next event loop to avoid // some control events that would trigger unnecessary rendering setTimeout(() => this.configureControls(), 0); } private configureControls() { switch (this.settings.controls) { case Control.ORBIT: { const controls = new OrbitControls(this.camera, this.renderer.domElement as HTMLElement); controls.rotateSpeed = 2.0; controls.zoomSpeed = 1.2; controls.panSpeed = 0.8; controls.enabled = true; this.controls = controls; break; } default: { const controls = new TrackballControls( this.camera, this.renderer.domElement as HTMLElement ); controls.rotateSpeed = 2.0; controls.zoomSpeed = 1.2; controls.panSpeed = 0.8; controls.enabled = true; controls.staticMoving = true; this.controls = controls; break; } } if ( this.settings.staticScene || this.settings.animation === AnimationStyle.NONE || this.settings.animation === AnimationStyle.SLIDER ) { // only re-render when scene is rotated this.controls.addEventListener('change', () => { this.dispatch(this.camera.position, this.camera.quaternion, this.camera.zoom); this.renderScene(); }); this.controls.addEventListener('start', () => { this.controls.update(); this.settings.controls === Control.TRACKBALL && document.addEventListener('mousemove', this.mouseTrackballUpdate, false); }); this.controls.addEventListener('end', () => { this.controls.update(); this.settings.controls === Control.TRACKBALL && document.removeEventListener('mousemove', this.mouseTrackballUpdate, false); }); } else { // constantly re-render (for animation) this.start(); } } private readonly mouseTrackballUpdate = () => { this.controls.update(); }; public updateCamera(position: Vector3, rotation?: Quaternion, zoom?: number) { this.camera.position.copy(position); if (zoom) { this.camera.zoom = zoom; } if (rotation) { this.camera.quaternion.copy(rotation); } else { this.camera.lookAt(this.scene.position); } this.camera.updateProjectionMatrix(); // needed for the zoom this.renderScene(); } private onClickImplementation(p, e) { let needRedraw = false; //TODO(chab) make it more readable if (p && p.object) { const { object, point } = p; if (object?.sceneObject) { const sceneObject: Object3D = object?.sceneObject; const jsonObject: Object3D = object?.jsonObject; if (this.isMultiSelectionEnabled) { // if the object is not in the registry, it just means it's the first time // we select it const objectIndex = this.outlineScene.children.indexOf( this.registry.getObjectFromRegistry(sceneObject.uuid) ); const jsonObjectIndex = this.selectedJsonObjects.indexOf(jsonObject); if ( (objectIndex === -1 && jsonObjectIndex > -1) || (jsonObjectIndex === -1 && objectIndex > -1) ) { console.warn( 'During selection found a THREE object without a corresponding json object ( or vice-versa' ); console.warn('THREE OBJECT', object, 'JSON', jsonObject); } if (jsonObjectIndex > -1) { this.selectedJsonObjects.splice(jsonObjectIndex, 1); } else { if (e.shiftKey) { this.selectedJsonObjects.push(jsonObject); } else { this.selectedJsonObjects = [jsonObject]; } } //TODO(chab) log warning if we have a json object without a three object, and vice-versa if (objectIndex > -1) { const object = this.outlineScene.children[objectIndex]; const sceneObject = this.registry.getObjectFromRegistry(object.uuid); this.outlineScene.remove(sceneObject); } else { if (!this.registry.registryHasObject(sceneObject)) { this.addClonedObject(sceneObject); } const threeObjectForOutlineScene = this.registry.getObjectFromRegistry( sceneObject.uuid ); if (e.shiftKey) { this.outlineScene.add(threeObjectForOutlineScene); } else { if (this.outlineScene.children.length > 0) { this.outlineScene.remove(...this.outlineScene.children); } this.outlineScene.add(threeObjectForOutlineScene); } } } else { disposeSceneHierarchy(this.outlineScene); if (!this.registry.registryHasObject(sceneObject)) { this.addClonedObject(sceneObject); } const threeObjectForOutlineScene = this.registry.getObjectFromRegistry(sceneObject.uuid); if (this.outlineScene.children.length > 0) { this.outlineScene.remove(...this.outlineScene.children); } this.outlineScene.add(threeObjectForOutlineScene); this.selectedJsonObjects = [jsonObject]; } needRedraw = true; } this.clickCallback(this.selectedJsonObjects); } else { if (this.selectedJsonObjects.length > 0) { this.clickCallback([]); } this.selectedJsonObjects = []; if (this.outlineScene.children.length > 0) { disposeSceneHierarchy(this.outlineScene); this.outlineScene.remove(...this.outlineScene.children); needRedraw = true; } } if (this.settings.secondaryObjectView) { this.outlineScene.children.length > 0 ? this.inset.showObject(this.outlineScene.children) : this.inset.showAxis(); } needRedraw && this.renderScene(); } private addClonedObject(sceneObject: THREE.Object3D) { const clone = sceneObject.clone(); clone.matrixAutoUpdate = false; clone.uuid = sceneObject.uuid; this.registry.addToObjectRegisty(clone); } public updateAnimationStyle(animationStyle: AnimationStyle) { this.settings.animation = animationStyle; switch (animationStyle) { case AnimationStyle.SLIDER: case AnimationStyle.NONE: { setTimeout(() => this.stop(), 0); break; } case AnimationStyle.PLAY: { setTimeout(() => this.start(), 0); } } } private readonly windowListener = () => this.resizeRendererToDisplaySize(); constructor( sceneJson, domElement: Element, settings, size, padding, tiling, maxTiling, clickCallback, private dispatch: (p: Vector3, r: Quaternion, zoom: number) => void, private debugDOMElement?, cameraState?: CameraState ) { this.tiling = tiling || 0; this.maxTiling = maxTiling || [0, 0, 0]; this.arrayOfTileRoots = Scene.getEmptyTilesArray([ this.maxTiling, this.maxTiling, this.maxTiling ]); this.settings = Object.assign(defaults, settings); this.objectBuilder = new ThreeBuilder(this.settings); this.cameraState = cameraState; this.cacheMountBBox(domElement); this.configureSceneRenderer(domElement); this.configureLabelRenderer(domElement); this.configureScene(); this.configurePostProcessing(); this.clickCallback = clickCallback; this.outlineScene.autoUpdate = false; this.animationHelper = new AnimationHelper(this.objectBuilder); window.addEventListener('resize', this.windowListener, false); this.inset = new InsetHelper( this.axis, this.axisJson, this.scene, sceneJson.origin, this.camera, this.objectBuilder, size, size, padding ); if (this.debugDOMElement) { this.debugHelper = this.getHelper(); } this.isMultiSelectionEnabled = this.settings.isMultiSelectionEnabled; } updateInsetSettings(inletSize: number, inletPadding: number, axisView) { this.inletPosition = axisView as ScenePosition; if (this.axis) { this.inset.updateViewportsize(inletSize, inletPadding); } this.renderInlet(); } /* loop through the arrayOfTileRoots and set the visibility of each object. In particular, set threeObject.visible = true if the x, y, and z indices are all less than the x, y, and z indices in tiling. */ updateTiles(tiling) { const [xCut, yCut, zCut] = tiling; this.arrayOfTileRoots.forEach((arrX, x) => { arrX.forEach((arrY, y) => { arrY.forEach((arrZ, z) => { arrZ[0].visible = x <= xCut && y <= yCut && z <= zCut; }); }); }); this.renderScene(); } public resizeRendererToDisplaySize() { const canvas = this.renderer.domElement as HTMLCanvasElement; this.cacheMountBBox(canvas.parentElement as Element); const { width, height } = this.cachedMountNodeSize; this.labelRenderer.setSize(width, height); this.labelRenderer.domElement.style.top = `-${height}px`; if (this.renderer instanceof SVGRenderer) { this.renderer.setSize(width, height); } if (canvas.width !== width || canvas.height !== height) { this.renderScene(); } } addToScene(sceneJson: SceneJsonObject, bypassRendering = false) { // we need to clarify the current semantics // currently, it will remove the old scene if the name is the same, // otherwise it will keep it // it will then zoom on the content of the added scene // if we found an object, we should remove all tootips and clicks related to it let outlinedObject: string[] = []; if (this.scene.getObjectByName(sceneJson.name!)) { console.log('Regenerating scene'); // see https://jsfiddle.net/L981td24/17/ this.animationHelper.reset(); this.clickableObjects = []; this.tooltipObjects = []; this.threeUUIDTojsonObject = {}; this.computeIdToThree = {}; this.registry.clear(); this.removeObjectByName(sceneJson.name!); if (this.outlineScene.children.length > 0) { outlinedObject = this.selectedJsonObjects.map((o) => o.id); console.log(outlinedObject); this.outlineScene.remove(...this.outlineScene.children); } this.selectedJsonObjects = []; } else { console.log('The scene is a new scene:', sceneJson.name); } const objectToAnimate = new Set(); /* this function returns an array of tiles based on the tiling array e.g. _getTiles([0, 1, 1] === [[0,0,0], [0,0,1], [0,1,1], [0,1,0]] */ const _getTiles = (tiling: number[]) => { // enumerate all tiles needed for a given tiling size let tiles: number[][] = []; for (let x: number = 0; x <= tiling[0]; x++) { for (let y: number = 0; y <= tiling[1]; y++) { for (let z: number = 0; z <= tiling[2]; z++) { tiles.push([x, y, z]); } } } return tiles; }; const _alternateTiles = (x: number) => { return (-1) ** (x + 1) * Math.trunc((x + 1) / 2); }; const emptyLattice = [ [0, 0, 0], [0, 0, 0], [0, 0, 0] ]; /* This function traverses through all the tiles and renders the SceneJsonObject once for each. Each new scene is a child of root and is offset according to the SceneJsonObject.lattice. Each scene is added to the arrayOfTilesRoots, it can be accessed by indexing through the arrayOfTileRoots. e.g. scene = arrayOfTileRoots[x][y][z][0]. */ const traverseTiles = (o: SceneJsonObject, root: THREE.Object3D, tiles: number[][]) => { // @ts-ignore let lattice = o.lattice ? o.lattice : emptyLattice; for (const tile of tiles) { const tileRootObject = new THREE.Object3D(); tileRootObject.name = sceneJson.name!; sceneJson.visible && (tileRootObject.visible = sceneJson.visible); root.add(tileRootObject); const [x, y, z] = tile; this.arrayOfTileRoots[x][y][z].push(tileRootObject); let tileOffsets: number[][] = lattice.map((vector: number[], index: number) => { return vector.map((x: number) => { return x * _alternateTiles(tile[index]); }); }); traverseScene(sceneJson, tileRootObject, tileOffsets, ''); } }; /* This is the core rendering loop of the Scene class. This function recursively traverses through the scene graph and render all objects that can be converted into threeObject's. It will apply the tileOffset to the rendered scenes, translating them relative to the parent threeObject. */ const traverseScene = ( o: SceneJsonObject, parent: THREE.Object3D, tileOffsets: number[][], currentId: string ) => { // create root and add to o.contents!.forEach((childObject, idx) => { if (childObject.type) { // render the threeObject according to childObject.type and end recursion const object = this.makeObject(childObject); parent.add(object); this.threeUUIDTojsonObject[object.uuid] = childObject; this.computeIdToThree[`${currentId}--${idx}`] = object; childObject.id = `${currentId}--${idx}`; if (childObject.animate) { objectToAnimate.add(`${currentId}--${idx}`); } } else { // create threeObject, save id, and add to arrayOfTileRoots const threeObject = new THREE.Object3D(); threeObject.name = childObject.name!; this.computeIdToThree[`${currentId}--${threeObject.name}`] = threeObject; childObject.id = `${currentId}--${threeObject.name}`; threeObject.visible = childObject.visible === undefined ? true : !!childObject.visible; // translate tile according to lattice vectors for (let offset of tileOffsets) { if (threeObject.name !== 'unit_cell') { const tilingTranslation = new THREE.Matrix4(); tilingTranslation.makeTranslation(...(offset as ThreePosition)); threeObject.applyMatrix4(tilingTranslation); } } // translate tile to scene origin if (childObject.origin) { const translation = new THREE.Matrix4(); // note(chab) have a typedefinition for the JSON translation.makeTranslation(...(childObject.origin as ThreePosition)); threeObject.applyMatrix4(translation); } if (!this.settings.extractAxis || threeObject.name !== 'axes') { parent.add(threeObject); } // recurse through Scene graph traverseScene(childObject, threeObject, tileOffsets, `${currentId}--${threeObject.name}`); if (threeObject.name === 'axes') { this.axis = threeObject.clone(); this.axisJson = { ...childObject }; } } }); }; // set up the threeObjects and containers const rootObject = new THREE.Object3D(); // set up the threeObjects and containers rootObject.name = 'root'; rootObject.visible = true; const maxTilingArray = [this.maxTiling, this.maxTiling, this.maxTiling]; // get list of tiles needed let tiles = _getTiles(maxTilingArray); // render all tiles traverseTiles(sceneJson, rootObject, tiles); // hide/show tiles as appropriate this.updateTiles(this.tiling); // can cause memory leak this.scene.add(rootObject); this.setupCamera(rootObject); // we try to update the outline from the preceding scene, but if the corresponding // object is not there, we'll remove the outline if (outlinedObject.length > 0) { this.outlineScene.remove(...this.outlineScene.children); outlinedObject.forEach((id) => { const three = this.computeIdToThree[id]; if (three) { this.addClonedObject(three); this.outlineScene.add(this.registry.getObjectFromRegistry(three.uuid)); this.selectedJsonObjects.push(this.threeUUIDTojsonObject[three.uuid]); } else { // object has been removed from new scene, so we do not add it } }); // update inlet this.outlineScene.children.length > 0 && this.inset.showObject(this.outlineScene.children); } // we can automatically output a screenshot to be the background of the parent div // this helps for automated testing, printing the web page, etc. if (this.settings.renderDivBackground) { this.renderer.domElement.parentElement!.style.backgroundSize = '100%'; this.renderer.domElement.parentElement!.style.backgroundRepeat = 'no-repeat'; this.renderer.domElement.parentElement!.style.backgroundPosition = 'center'; if (this.renderer.domElement instanceof HTMLCanvasElement) { // TS magic, domElements is automatically coerced to HTMLCanvasElement this.renderer.domElement.parentElement!.style.backgroundImage = `url('${this.renderer.domElement.toDataURL( 'image/png' )}')`; } } //FIXME(chab) try to move that before if (this.inset && !!this.axis && !!this.axisJson && this.outlineScene.children.length === 0) { this.inset.setAxis(this.axis, this.axisJson); this.inset.updateSelectedObject(this.axis, this.axisJson); } objectToAnimate.forEach((id: string) => { const three = this.computeIdToThree[id]; const json: SceneJsonObject = this.threeUUIDTojsonObject[three.uuid]; this.animationHelper.buildAnimationSupport(json, three); }); if (!bypassRendering) { this.renderScene(); } } private setupCamera(rootObject: THREE.Object3D) { // auto-zoom to fit object // TODO: maybe better to move this elsewhere (what if using perspective?) const box = new THREE.Box3(); box.setFromObject(rootObject); const center = new THREE.Vector3(); box.getCenter(center); const size = new THREE.Vector3(); box.getSize(size); const extent = box.max.sub(box.min); let length = extent.length() * 2; if (this.settings.zoomToFit2D) { length = extent.x > extent.y ? extent.x * 2 : extent.y * 2; } // we add a bit of padding, let's suppose we rotate, we want to avoid the // object to go out of the camera while still on the screen const Z_PADDING = 50; if (this.camera) { this.camera.left = (center.x - length) / this.settings.defaultZoom; this.camera.right = (center.x + length) / this.settings.defaultZoom; this.camera.top = (center.y + length) / this.settings.defaultZoom; this.camera.bottom = (center.y - length) / this.settings.defaultZoom; this.camera.near = center.z - length - Z_PADDING; this.camera.far = center.z + length + Z_PADDING; } else { this.camera = new THREE.OrthographicCamera( center.x - length, center.x + length, center.y + length, center.y - length, center.z - length - Z_PADDING, center.z + length + Z_PADDING ); } // we put the camera behind the object, object should be in the middle of the view, closer to the far plane this.camera.position.z = center.z; this.camera.position.y = center.y; this.camera.position.x = center.x; const axis = this.settings.cameraAxis; this.camera.position[axis] = this.settings.cameraPosition === 'back' ? this.camera.position[axis] + length / 2 : this.camera.position[axis] - length / 2; this.camera.lookAt(this.scene.position); this.camera.zoom = 4; this.camera.updateProjectionMatrix(); this.camera.updateMatrix(); if (this.controls) { this.controls.update(); } } makeObject(object_json): THREE.Object3D { const obj = new THREE.Object3D(); if (object_json.clickable) { this.clickableObjects.push(obj); this.objectDictionnary[obj.id] = object_json; } if (object_json.tooltip) { this.tooltipObjects.push(obj); this.objectDictionnary[obj.id] = object_json; } return this.objectBuilder.makeObject(object_json, obj); } start() { if (!this.frameId) { this.frameId = requestAnimationFrame(() => this.animate()); } else { console.warn('Trying to start animation, but it seems an animation loop is already running'); } } stop() { cancelAnimationFrame(this.frameId!); this.frameId = undefined; } animate() { this.animationHelper.animate(); this.controls.update(); this.refreshOutline(); this.renderScene(); this.frameId = window.requestAnimationFrame(() => this.animate()); } renderScene() { if (this.renderer instanceof WebGLRenderer) { this.renderer.clear(); this.renderer.setSize(this.cachedMountNodeSize.width, this.cachedMountNodeSize.height); //TODO(chab) not sure to understand why we have to turn on/off scissor tests between renderings this.renderer.setScissorTest(true); this.renderer.setScissor( 0, 0, this.cachedMountNodeSize.width, this.cachedMountNodeSize.height ); this.renderer.setViewport( 0, 0, this.cachedMountNodeSize.width, this.cachedMountNodeSize.height ); } this.renderer.render(this.scene, this.camera); if (this.outline && this.outlineScene.children.length > 0) { this.outline.renderOutline(this.outlineScene, this.camera); } this.labelRenderer.render(this.scene, this.camera); if (this.renderer instanceof WebGLRenderer) { (this.renderer as any).clearDepth(); } // debug view if (this.debugHelper) { this.debugHelper.render(); } //TODO(chab) make a dedicated rendering for SVG this.renderInlet(); } private renderInlet() { this.inset && this.inletPosition !== ScenePosition.HIDDEN && this.inset.render(this.renderer, this.getInletOrigin(this.inletPosition)); } toggleVisibility(namesToVisibility: { [objectName: string]: boolean }) { if (!!namesToVisibility && Object.keys(namesToVisibility).length > 0) { Object.keys(namesToVisibility).forEach((objName) => { const obj = this.scene.getObjectByName(objName); if (obj) { obj.visible = !!namesToVisibility[objName]; } }); // check all outlined objects, for each outlined object, their ancestors visibility can be false // if it's the case, we'll need to remove the outlined object // note that we consider that the selection is lost const idsToRemove: string[] = []; this.selectedJsonObjects = this.selectedJsonObjects.filter((o) => { let threeobject = this.computeIdToThree[o.id]; let visible = true; if (!threeobject.visible) { idsToRemove.push(threeobject.uuid); return false; } else { const baseObject = threeobject; // walk the object hierarchy to check if parent are visible while (threeobject.parent && visible) { threeobject = threeobject.parent; visible = threeobject.visible; } // if it's not visible, remove it !visible && idsToRemove.push(baseObject.uuid); } return visible; }); idsToRemove.forEach((id) => { const outlineObject = this.registry.getObjectFromRegistry(id); this.outlineScene.remove(outlineObject); // remove from inlet too }); this.renderScene(); } } // i know this is can be done by implementing a color buffer, with each color matching one // object getClickedReference(clientX: number, clientY: number, objectsToCheck: Object3D[]) { //FIXME(chab) ideally we should recompute the objectsToCheck array for better performance if (!objectsToCheck || objectsToCheck.length === 0) { return; } const size = new THREE.Vector2(this.cachedMountNodeSize.width, this.cachedMountNodeSize.height); this.raycaster.setFromCamera(getThreeScreenCoordinate(size, clientX, clientY), this.camera); const intersects = this.raycaster.intersectObjects(objectsToCheck, true); if (intersects.length > 0) { // we catch the first object that the ray touches let point = intersects[0].point; const screenPoint = getScreenCoordinate(this.cachedMountNodeSize, point, this.camera); const finalPoint = moveAndUnprojectPoint(this.cachedMountNodeSize, screenPoint, this.camera, { x: 0, y: -30 }); const info = { point: finalPoint, object: this.getParentObject(intersects[0].object) }; return info; } return null; } getParentObject(object: Object3D): { sceneObject: Object3D; jsonObject: any } | null { if (!object.parent || !object.parent.visible || !object.visible) { return null; } if (!this.objectDictionnary[object.id]) { return this.getParentObject(object.parent); } else { return { sceneObject: object, jsonObject: this.objectDictionnary[object.id] }; } } public enableDebug(debugEnabled: boolean, node) { if (!debugEnabled) { if (!this.debugHelper) { // } else { this.debugHelper.onDestroy(); this.debugHelper = null as unknown as DebugHelper; } } else { if (this.debugHelper) { // } else { this.debugDOMElement = node; this.debugHelper = this.getHelper(); this.debugHelper.render(); } } } public removeListener() { window.removeEventListener('resize', this.windowListener, false); this.renderer.domElement.removeEventListener('mousemove', this.mouseMoveListener); this.renderer.domElement.removeEventListener('click', this.clickListener); document.removeEventListener('mousemove', this.mouseTrackballUpdate, false); } // call this when the parent component is destroyed public onDestroy() { this.computeIdToThree = {}; this.threeUUIDTojsonObject = {}; this.removeListener(); this.debugHelper && this.debugHelper.onDestroy(); this.inset.onDestroy(); this.controls.dispose(); disposeSceneHierarchy(this.scene); this.scene.dispose(); if (this.renderer instanceof THREE.WebGLRenderer) { this.renderer.forceContextLoss(); this.renderer.dispose(); } this.renderer.domElement!.parentElement!.removeChild(this.renderer.domElement); this.renderer.domElement = undefined as any; this.renderer = null as any; this.stop(); } removeObjectByName(name: string) { // name is not necessarily unique, make this recursive ? const object = this.scene.getObjectByName(name); typeof object !== 'undefined' && this.scene.remove(object); } private getHelper() { return new DebugHelper( this.debugDOMElement, this.scene, this.camera, this.settings, this.objectBuilder, this.inset.helper ); } private getInletOrigin(pos: ScenePosition): [number, number] { switch (pos) { case ScenePosition.SW: { return [this.inset.getPadding(), this.inset.getPadding()]; } case ScenePosition.SE: { return [ this.cachedMountNodeSize.width - this.inset.getPadding() - this.inset.getSize(), this.inset.getPadding() ]; } case ScenePosition.NW: { return [ 0 + this.inset.getPadding(), this.cachedMountNodeSize.height - this.inset.getPadding() - this.inset.getSize() ]; } case ScenePosition.NE: { return [ this.cachedMountNodeSize.width - this.inset.getPadding() - this.inset.getSize(), this.cachedMountNodeSize.height - this.inset.getPadding() - this.inset.getSize() ]; } default: return [this.inset.getPadding(), this.inset.getPadding()]; } } private configurePostProcessing() { if (this.settings.renderer === Renderer.SVG) { console.warn('No post processing pass for SVG'); return; } //TODO(chab) look at three.js to implement the texture const outline = new OutlineEffect(this.renderer as WebGLRenderer, { defaultThickness: 0.01, defaultColor: [0, 0, 0], defaultAlpha: 1.0, defaultKeepAlive: true // keeps outline material in cache even if material is removed from scene }); this.outline = outline; } public findObjectByUUID(uuid: string) { const threeObject = this.scene.getObjectByProperty('uuid', uuid); const jsonObject = this.threeUUIDTojsonObject[uuid]; return { threeObject, jsonObject }; } refreshOutline() { let outlinedObject: any[] = []; if (this.outlineScene.children.length > 0) { outlinedObject = this.selectedJsonObjects.map((o: any) => o.id); this.outlineScene.remove(...this.outlineScene.children); } if (outlinedObject.length > 0) { this.outlineScene.remove(...this.outlineScene.children); outlinedObject.forEach((id) => { const three = this.computeIdToThree[id]; this.addClonedObject(three); this.outlineScene.add(this.registry.getObjectFromRegistry(three.uuid)); }); } } updateTime(time: number) { this.animationHelper.updateTime(time); this.refreshOutline(); this.renderScene(); } }