diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b35537de..0ed4a49eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). * New viewer for human-readable text files ([#1826](https://github.com/lbryio/lbry-desktop/pull/1826)) * CSV and JSON viewer ([#1410](https://github.com/lbryio/lbry-desktop/pull/1410)) * Recommended content on file viewer page ([#1845](https://github.com/lbryio/lbry-desktop/pull/1845)) + * 3D File viewer improvements ([#1870](https://github.com/lbryio/lbry-desktop/pull/1870)) * Desktop notification when publish is completed ([#1892](https://github.com/lbryio/lbry-desktop/pull/1892)) ### Changed diff --git a/package.json b/package.json index a8c5a22bb..a7d9e7bfe 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "classnames": "^2.2.5", "codemirror": "^5.39.2", "country-data": "^0.0.31", + "dat.gui": "^0.7.2", "dom-scroll-into-view": "^1.2.1", "electron-dl": "^1.11.0", "electron-is-dev": "^0.3.0", diff --git a/src/renderer/component/viewers/threeViewer/index.jsx b/src/renderer/component/viewers/threeViewer/index.jsx index 751c84dd2..0f4afb433 100644 --- a/src/renderer/component/viewers/threeViewer/index.jsx +++ b/src/renderer/component/viewers/threeViewer/index.jsx @@ -1,6 +1,9 @@ // @flow import * as React from 'react'; +import * as dat from 'dat.gui'; +import classNames from 'classnames'; import LoadingScreen from 'component/common/loading-screen'; + // ThreeJS import * as THREE from './internal/three'; import detectWebGL from './internal/detector'; @@ -11,123 +14,39 @@ import ThreeRenderer from './internal/renderer'; type Props = { theme: string, - autoRotate: boolean, source: { fileType: string, downloadPath: string, }, }; -class ThreeViewer extends React.PureComponent { - constructor(props: Props) { - super(props); +type State = { + error: ?string, + isReady: boolean, + isLoading: boolean, +}; - const { theme } = this.props; +class ThreeViewer extends React.PureComponent { + static testWebgl = new Promise((resolve, reject) => { + if (detectWebGL()) resolve(); + else reject(); + }); - // Main container - this.viewer = React.createRef(); - - // Object colors - this.materialColors = { - red: '#e74c3c', - blue: '#3498db', - green: '#44b098', - orange: '#f39c12', - }; - - // Viewer themes - this.themes = { - dark: { - gridColor: '#414e5c', - groundColor: '#13233C', - backgroundColor: '#13233C', - centerLineColor: '#7f8c8d', - }, - light: { - gridColor: '#7f8c8d', - groundColor: '#DDD', - backgroundColor: '#EEE', - centerLineColor: '#2F2F2F', - }, - }; - - // Select current theme - this.theme = this.themes[theme] || this.themes.light; - - // State - this.state = { - error: null, - isReady: false, - isLoading: false, - }; - } - - componentDidMount() { - if (detectWebGL()) { - this.renderScene(); - // Update render on resize window - window.addEventListener('resize', this.handleResize, false); - } else { - // No webgl support, handle Error... - // TODO: Use a better error message - this.setState({ error: "Sorry, your computer doesn't support WebGL." }); - } - } - - componentWillUnmount() { - window.removeEventListener('resize', this.handleResize, false); - } - - transformGroup(group) { - this.fitMeshToCamera(group); - this.createWireFrame(group); - this.updateControlsTarget(group.position); - } - - createOrbitControls(camera, canvas) { - const { autoRotate } = this.props; + static createOrbitControls(camera, canvas) { const controls = new THREE.OrbitControls(camera, canvas); // Controls configuration controls.enableDamping = true; controls.dampingFactor = 0.75; controls.enableZoom = true; - controls.minDistance = 1; - controls.maxDistance = 50; - controls.autoRotate = autoRotate; + controls.minDistance = 5; + controls.maxDistance = 14; + controls.autoRotate = false; controls.enablePan = false; + controls.saveState(); return controls; } - createGeometry(data) { - const geometry = new THREE.Geometry(); - geometry.fromBufferGeometry(data); - geometry.computeBoundingBox(); - geometry.computeVertexNormals(); - geometry.center(); - geometry.rotateX(-Math.PI / 2); - geometry.lookAt(new THREE.Vector3(0, 0, 1)); - return geometry; - } - - createWireFrame(group) { - const wireframeGeometry = new THREE.WireframeGeometry(group.geometry); - const wireframeMaterial = new THREE.LineBasicMaterial({ - opacity: 0, - transparent: true, - linewidth: 1, - }); - // Set material color - wireframeMaterial.color.set(this.materialColors.green); - this.wireframe = new THREE.LineSegments(wireframeGeometry, wireframeMaterial); - group.add(this.wireframe); - } - - toggleWireFrame(show = false) { - this.wireframe.opacity = show ? 1 : 0; - this.mesh.material.opacity = show ? 0 : 1; - } - - fitMeshToCamera(group) { + static fitMeshToCamera(group) { const max = { x: 0, y: 0, z: 0 }; const min = { x: 0, y: 0, z: 0 }; @@ -147,20 +66,214 @@ class ThreeViewer extends React.PureComponent { const meshY = Math.abs(max.y - min.y); const meshX = Math.abs(max.x - min.x); - const scaleFactor = 10 / Math.max(meshX, meshY); group.scale.set(scaleFactor, scaleFactor, scaleFactor); group.position.setY((meshY / 2) * scaleFactor); - // Reset object position const box = new THREE.Box3().setFromObject(group); + // Update position box.getCenter(group.position); - group.position.multiplyScalar(-1); group.position.setY(group.position.y + meshY * scaleFactor); } + /* + See: https://github.com/mrdoob/three.js/blob/dev/docs/scenes/js/material.js#L195 + */ + + static updateMaterial(material, geometry) { + material.vertexColors = +material.vertexColors; // Ensure number + material.side = +material.side; // Ensure number + material.needsUpdate = true; + // If Geometry needs update + if (geometry) { + geometry.verticesNeedUpdate = true; + geometry.normalsNeedUpdate = true; + geometry.colorsNeedUpdate = true; + } + } + + constructor(props: Props) { + super(props); + const { theme } = this.props; + this.viewer = React.createRef(); + this.guiContainer = React.createRef(); + // Object defualt color + this.materialColor = '#44b098'; + // Viewer themes + this.themes = { + dark: { + gridColor: '#414e5c', + groundColor: '#13233C', + backgroundColor: '#13233C', + centerLineColor: '#7f8c8d', + }, + light: { + gridColor: '#7f8c8d', + groundColor: '#DDD', + backgroundColor: '#EEE', + centerLineColor: '#2F2F2F', + }, + }; + // Select current theme + this.theme = this.themes[theme] || this.themes.light; + // State + this.state = { + error: null, + isReady: false, + isLoading: false, + }; + } + + componentDidMount() { + ThreeViewer.testWebgl + .then(() => { + this.renderScene(); + // Update render on resize window + window.addEventListener('resize', this.handleResize, false); + }) + .catch(() => { + // No webgl support, handle Error... + // TODO: Use a better error message + this.setState({ error: "Sorry, your computer doesn't support WebGL." }); + }); + } + + componentWillUnmount() { + // Remove event listeners + window.removeEventListener('resize', this.handleResize, false); + // Free memory + if (this.renderer && this.mesh) { + // Clean up group + this.scene.remove(this.mesh); + if (this.mesh.geometry) this.mesh.geometry.dispose(); + if (this.mesh.material) this.mesh.material.dispose(); + // Cleanup shared geometry + if (this.geometry) this.geometry.dispose(); + if (this.bufferGeometry) this.bufferGeometry.dispose(); + // Clean up shared material + if (this.material) this.material.dispose(); + // Clean up grid + if (this.grid) { + this.grid.material.dispose(); + this.grid.geometry.dispose(); + } + // Clean up group items + this.mesh.traverse(child => { + if (child instanceof THREE.Mesh) { + if (child.geometry) child.geometry.dispose(); + if (child.material) child.material.dispose(); + } + }); + // Clean up controls + if (this.controls) this.controls.dispose(); + // It's unclear if we need this: + if (this.renderer) { + this.renderer.renderLists.dispose(); + this.renderer.dispose(); + } + // Stop animation + cancelAnimationFrame(this.frameID); + // Destroy GUI Controls + if (this.gui) this.gui.destroy(); + // Empty objects + this.grid = null; + this.mesh = null; + this.renderer = null; + this.material = null; + this.geometry = null; + this.bufferGeometry = null; + } + } + + transformGroup(group) { + ThreeViewer.fitMeshToCamera(group); + + if (!this.targetCenter) { + const box = new THREE.Box3(); + this.targetCenter = box.setFromObject(this.mesh).getCenter(); + } + this.updateControlsTarget(this.targetCenter); + } + + createInterfaceControls() { + if (this.guiContainer && this.mesh) { + this.gui = new dat.GUI({ autoPlace: false, name: 'controls' }); + + const config = { + color: this.materialColor, + }; + + config.reset = () => { + // Reset material color + config.color = this.materialColor; + // Reset material + this.material.color.set(config.color); + this.material.flatShading = true; + this.material.shininess = 30; + this.material.wireframe = false; + // Reset autoRotate + this.controls.autoRotate = false; + // Reset camera + this.restoreCamera(); + }; + + const materialFolder = this.gui.addFolder('Material'); + + // Color picker + const colorPicker = materialFolder + .addColor(config, 'color') + .name('Color') + .listen(); + + colorPicker.onChange(color => { + this.material.color.set(color); + }); + + materialFolder + .add(this.material, 'shininess', 0, 100) + .name('Shininess') + .listen(); + + materialFolder + .add(this.material, 'flatShading') + .name('FlatShading') + .onChange(() => { + ThreeViewer.updateMaterial(this.material); + }) + .listen(); + + materialFolder + .add(this.material, 'wireframe') + .name('Wireframe') + .listen(); + + const sceneFolder = this.gui.addFolder('Scene'); + + sceneFolder + .add(this.controls, 'autoRotate') + .name('Auto-Rotate') + .listen(); + + sceneFolder.add(config, 'reset').name('Reset'); + + this.guiContainer.current.appendChild(this.gui.domElement); + } + } + + createGeometry(data) { + this.bufferGeometry = data; + this.bufferGeometry.computeBoundingBox(); + this.bufferGeometry.center(); + this.bufferGeometry.rotateX(-Math.PI / 2); + this.bufferGeometry.lookAt(new THREE.Vector3(0, 0, 1)); + // Get geometry from bufferGeometry + this.geometry = new THREE.Geometry().fromBufferGeometry(this.bufferGeometry); + this.geometry.mergeVertices(); + this.geometry.computeVertexNormals(); + } + startLoader() { const { source } = this.props; @@ -179,6 +292,8 @@ class ThreeViewer extends React.PureComponent { handleReady = () => { this.setState({ isReady: true, isLoading: false }); + // GUI + this.createInterfaceControls(); }; handleError = () => { @@ -193,32 +308,29 @@ class ThreeViewer extends React.PureComponent { this.renderer.setSize(width, height); }; - handleColorChange(color) { - if (!this.mesh) return; - const pickColor = this.materialColors[color] || this.materialColors.green; - this.mesh.material.color.set(pickColor); - this.wireframe.material.color.set(pickColor); - } - updateControlsTarget(point) { this.controls.target.fromArray([point.x, point.y, point.z]); this.controls.update(); } + restoreCamera() { + this.controls.reset(); + this.camera.position.set(-9.5, 14, 11); + this.updateControlsTarget(this.targetCenter); + } + renderStl(data) { - const geometry = this.createGeometry(data); - const group = new THREE.Mesh(geometry, this.material); - // Assign name - group.name = 'objectGroup'; - this.scene.add(group); - this.transformGroup(group); - this.mesh = group; + this.createGeometry(data); + this.mesh = new THREE.Mesh(this.geometry, this.material); + this.mesh.name = 'model'; + this.scene.add(this.mesh); + this.transformGroup(this.mesh); } renderObj(event) { const mesh = event.detail.loaderRootNode; - const group = new THREE.Group(); - group.name = 'objGroup'; + this.mesh = new THREE.Group(); + this.mesh.name = 'model'; // Assign new material mesh.traverse(child => { @@ -226,15 +338,18 @@ class ThreeViewer extends React.PureComponent { // Get geometry from child const geometry = new THREE.Geometry(); geometry.fromBufferGeometry(child.geometry); + geometry.mergeVertices(); + geometry.computeVertexNormals(); // Create and regroup inner objects const innerObj = new THREE.Mesh(geometry, this.material); - group.add(innerObj); + this.mesh.add(innerObj); + // Clean up geometry + geometry.dispose(); + child.geometry.dispose(); } }); - - this.scene.add(group); - this.transformGroup(group); - this.mesh = group; + this.scene.add(this.mesh); + this.transformGroup(this.mesh); } renderModel(fileType, parsedData) { @@ -254,6 +369,7 @@ class ThreeViewer extends React.PureComponent { this.renderer = ThreeRenderer({ antialias: true, shadowMap: true, + gammaCorrection: true, }); this.scene = ThreeScene({ @@ -268,54 +384,56 @@ class ThreeViewer extends React.PureComponent { // Grid this.grid = ThreeGrid({ size: 100, gridColor, centerLineColor }); this.scene.add(this.grid); - // Camera this.camera = new THREE.PerspectiveCamera(80, width / height, 0.1, 1000); this.camera.position.set(-9.5, 14, 11); // Controls - this.controls = this.createOrbitControls(this.camera, canvas); + this.controls = ThreeViewer.createOrbitControls(this.camera, canvas); // Set viewer size this.renderer.setSize(width, height); // Create model material this.material = new THREE.MeshPhongMaterial({ - opacity: 1, - transparent: true, - // depthWrite: true, + depthWrite: true, + flatShading: true, vertexColors: THREE.FaceColors, - // Positive value pushes polygon further away - // polygonOffsetFactor: 1, - // polygonOffsetUnits: 1, }); + // Set material color - this.material.color.set(this.materialColors.green); + this.material.color.set(this.materialColor); // Load file and render mesh this.startLoader(); + // Append canvas + viewer.appendChild(canvas); + const updateScene = () => { - requestAnimationFrame(updateScene); - this.controls.update(); + this.frameID = requestAnimationFrame(updateScene); + if (this.controls.autoRotate) this.controls.update(); this.renderer.render(this.scene, this.camera); }; updateScene(); - // Append canvas - viewer.appendChild(canvas); } render() { + const { theme } = this.props; const { error, isReady, isLoading } = this.state; - const loadingMessage = 'Loading 3D model.'; + const loadingMessage = __('Loading 3D model.'); const showViewer = isReady && !error; const showLoading = isLoading && !error; + // Adaptive theme for gui controls + const containerClass = classNames('gui-container', { light: theme === 'light' }); + return ( {error && } {showLoading && } +
{ const divisions = size / 2; const grid = new GridHelper(size, divisions, new Color(centerLineColor), new Color(gridColor)); - grid.material.opacity = 0.4; grid.material.transparent = true; - return grid; }; diff --git a/src/renderer/component/viewers/threeViewer/internal/renderer.js b/src/renderer/component/viewers/threeViewer/internal/renderer.js index 11b6c0545..7ddefd48a 100644 --- a/src/renderer/component/viewers/threeViewer/internal/renderer.js +++ b/src/renderer/component/viewers/threeViewer/internal/renderer.js @@ -1,14 +1,13 @@ import { WebGLRenderer } from './three'; -const ThreeRenderer = ({ antialias, shadowMap }) => { - const renderer = new WebGLRenderer({ - antialias, - }); +const ThreeRenderer = ({ antialias, shadowMap, gammaCorrection }) => { + const renderer = new WebGLRenderer({ antialias }); // Renderer configuration renderer.setPixelRatio(window.devicePixelRatio); - renderer.gammaInput = true; - renderer.gammaOutput = true; - renderer.shadowMap.enabled = shadowMap; + renderer.gammaInput = gammaCorrection || false; + renderer.gammaOutput = gammaCorrection || false; + renderer.shadowMap.enabled = shadowMap || false; + renderer.shadowMap.autoUpdate = false; return renderer; }; diff --git a/src/renderer/scss/all.scss b/src/renderer/scss/all.scss index b8db82c0b..4b9bae942 100644 --- a/src/renderer/scss/all.scss +++ b/src/renderer/scss/all.scss @@ -28,3 +28,4 @@ @import 'component/_search.scss'; @import 'component/_toggle.scss'; @import 'component/_search.scss'; +@import 'component/_dat-gui.scss'; diff --git a/src/renderer/scss/component/_dat-gui.scss b/src/renderer/scss/component/_dat-gui.scss new file mode 100644 index 000000000..4cc69cdf3 --- /dev/null +++ b/src/renderer/scss/component/_dat-gui.scss @@ -0,0 +1,89 @@ +/* +* dat.gui component +*/ + +.gui-container { + position: absolute; + top: 0; + right: 0; + z-index: 2; + + .dg.main { + padding: 0; + margin: 0; + overflow: inherit; + } +} + +/* +* Light theme: +* https://github.com/liabru/dat-gui-light-theme +*/ + +.gui-container.light { + .dg.main.taller-than-window .close-button { + border-top: 1px solid #ddd; + } + + .dg.main .close-button { + background-color: #e8e8e8; + } + + .dg.main .close-button:hover { + background-color: #ddd; + } + + .dg { + color: #555; + text-shadow: none !important; + } + + .dg.main::-webkit-scrollbar { + background: #fafafa; + } + + .dg.main::-webkit-scrollbar-thumb { + background: #bbb; + } + + .dg li:not(.folder) { + background: #fafafa; + border-bottom: 1px solid #ddd; + } + + .dg li.save-row .button { + text-shadow: none !important; + } + + .dg li.title { + background: #e8e8e8 + url(data:image/gif;base64,R0lGODlhBQAFAJEAAP////Pz8////////yH5BAEAAAIALAAAAAAFAAUAAAIIlI+hKgFxoCgAOw==) + 6px 10px no-repeat; + } + + .dg .cr.function:hover, + .dg .cr.boolean:hover { + background: #fff; + } + + .dg .c input[type='text'] { + background: #e9e9e9; + } + + .dg .c input[type='text']:hover { + background: #eee; + } + + .dg .c input[type='text']:focus { + background: #eee; + color: #555; + } + + .dg .c .slider { + background: #e9e9e9; + } + + .dg .c .slider:hover { + background: #eee; + } +} diff --git a/yarn.lock b/yarn.lock index d52cf43b2..83104ad53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2514,6 +2514,10 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +dat.gui@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/dat.gui/-/dat.gui-0.7.2.tgz#57056f286d0f989e83f5adec196f24fd69c01ae3" + date-fns@^1.27.2: version "1.29.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"