Add 3D-file-viewer component #1558
11 changed files with 423 additions and 1 deletions
|
@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
* Added 3D file viewer for OBJ & STL file types ([#1558](https://github.com/lbryio/lbry-desktop/pull/1558))
|
||||||
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -78,6 +78,7 @@
|
||||||
"shapeshift.io": "^1.3.1",
|
"shapeshift.io": "^1.3.1",
|
||||||
"source-map-support": "^0.5.4",
|
"source-map-support": "^0.5.4",
|
||||||
"stream-to-blob-url": "^2.1.1",
|
"stream-to-blob-url": "^2.1.1",
|
||||||
|
"three": "^0.93.0",
|
||||||
"tree-kill": "^1.1.0",
|
"tree-kill": "^1.1.0",
|
||||||
"y18n": "^4.0.0"
|
"y18n": "^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -53,6 +53,13 @@ app.setAsDefaultProtocolClient('lbry');
|
||||||
app.setName('LBRY');
|
app.setName('LBRY');
|
||||||
app.setAppUserModelId('io.lbry.LBRY');
|
app.setAppUserModelId('io.lbry.LBRY');
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// Enable WEBGL
|
||||||
|
app.commandLine.appendSwitch('ignore-gpu-blacklist');
|
||||||
|
app.commandLine.appendSwitch('--disable-gpu-process-crash-limit');
|
||||||
|
app.disableDomainBlockingFor3DAPIs();
|
||||||
|
}
|
||||||
|
|
||||||
app.on('ready', async () => {
|
app.on('ready', async () => {
|
||||||
const processList = await findProcess('name', 'lbrynet-daemon');
|
const processList = await findProcess('name', 'lbrynet-daemon');
|
||||||
const isDaemonRunning = processList.length > 0;
|
const isDaemonRunning = processList.length > 0;
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import LoadingScreen from 'component/common/loading-screen';
|
import LoadingScreen from 'component/common/loading-screen';
|
||||||
import PdfViewer from 'component/viewers/pdfViewer';
|
import PdfViewer from 'component/viewers/pdfViewer';
|
||||||
|
import ThreeViewer from 'component/viewers/threeViewer';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mediaType: string,
|
mediaType: string,
|
||||||
|
@ -20,7 +21,7 @@ class FileRender extends React.PureComponent<Props> {
|
||||||
|
|
||||||
// Supported mediaTypes
|
// Supported mediaTypes
|
||||||
const mediaTypes = {
|
const mediaTypes = {
|
||||||
// '3D-file': <ThreeViewer {...viewerProps}/>,
|
'3D-file': <ThreeViewer {...viewerProps} />,
|
||||||
// Add routes to viewer...
|
// Add routes to viewer...
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
281
src/renderer/component/viewers/threeViewer/index.jsx
Normal file
281
src/renderer/component/viewers/threeViewer/index.jsx
Normal file
|
@ -0,0 +1,281 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import LoadingScreen from 'component/common/loading-screen';
|
||||||
|
// ThreeJS
|
||||||
|
import * as THREE from './internal/three';
|
||||||
|
import detectWebGL from './internal/detector';
|
||||||
|
import ThreeScene from './internal/scene';
|
||||||
|
import ThreeLoader from './internal/loader';
|
||||||
|
import ThreeRenderer from './internal/renderer';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
theme: string,
|
||||||
|
autoRotate: boolean,
|
||||||
|
source: {
|
||||||
|
fileType: string,
|
||||||
|
filePath: string,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
class ThreeViewer extends React.PureComponent<Props> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
const { theme } = this.props;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
createOrbitControls(camera, canvas) {
|
||||||
|
const { autoRotate } = this.props;
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
createMesh(geometry) {
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
opacity: 1,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: true,
|
||||||
|
vertexColors: THREE.FaceColors,
|
||||||
|
// Positive value pushes polygon further away
|
||||||
|
polygonOffsetFactor: 1,
|
||||||
|
polygonOffsetUnits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set material color
|
||||||
|
material.color.set(this.materialColors.green);
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
|
||||||
|
// Assign name
|
||||||
|
mesh.name = 'objectGroup';
|
||||||
|
|
||||||
|
this.scene.add(mesh);
|
||||||
|
this.fitMeshToCamera(mesh);
|
||||||
|
this.createWireFrame(mesh);
|
||||||
|
this.updateControlsTarget(mesh.position);
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleWireFrame(show = false) {
|
||||||
|
this.wireframe.opacity = show ? 1 : 0;
|
||||||
|
this.mesh.material.opacity = show ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fitMeshToCamera(group) {
|
||||||
|
const max = { x: 0, y: 0, z: 0 };
|
||||||
|
const min = { x: 0, y: 0, z: 0 };
|
||||||
|
|
||||||
|
group.traverse(child => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
const box = new THREE.Box3().setFromObject(group);
|
||||||
|
// Max
|
||||||
|
max.x = box.max.x > max.x ? box.max.x : max.x;
|
||||||
|
max.y = box.max.y > max.y ? box.max.y : max.y;
|
||||||
|
max.z = box.max.z > max.z ? box.max.z : max.z;
|
||||||
|
// Min
|
||||||
|
min.x = box.min.x < min.x ? box.min.x : min.x;
|
||||||
|
min.y = box.min.y < min.y ? box.min.y : min.y;
|
||||||
|
min.z = box.min.z < min.z ? box.min.z : min.z;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const meshY = Math.abs(max.y - min.y);
|
||||||
|
const meshX = Math.abs(max.x - min.x);
|
||||||
|
const scaleFactor = 15 / Math.max(meshX, meshY);
|
||||||
|
|
||||||
|
group.scale.set(scaleFactor, scaleFactor, scaleFactor);
|
||||||
|
group.position.setY((meshY / 2) * scaleFactor);
|
||||||
|
group.position.multiplyScalar(-1);
|
||||||
|
group.position.setY((meshY * scaleFactor) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
startLoader() {
|
||||||
|
const { source } = this.props;
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
ThreeLoader(source, this.renderModel.bind(this), {
|
||||||
|
onStart: this.handleStart,
|
||||||
|
onLoad: this.handleReady,
|
||||||
|
onError: this.handleError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStart = () => {
|
||||||
|
this.setState({ isLoading: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReady = () => {
|
||||||
|
this.setState({ isReady: true, isLoading: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleError = () => {
|
||||||
|
this.setState({ error: "Sorry, looks like we can't load this file" });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleResize = () => {
|
||||||
|
const { offsetWidth: width, offsetHeight: height } = this.viewer.current;
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.controls.update();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModel(fileType, data) {
|
||||||
|
const geometry = this.createGeometry(data);
|
||||||
|
this.mesh = this.createMesh(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderScene() {
|
||||||
|
this.renderer = ThreeRenderer({
|
||||||
|
antialias: true,
|
||||||
|
shadowMap: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene = ThreeScene({
|
||||||
|
showFog: true,
|
||||||
|
showGrid: true,
|
||||||
|
...this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewer = this.viewer.current;
|
||||||
|
const canvas = this.renderer.domElement;
|
||||||
|
const { offsetWidth: width, offsetHeight: height } = viewer;
|
||||||
|
// 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);
|
||||||
|
// Set viewer size
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
// Load file and render mesh
|
||||||
|
this.startLoader();
|
||||||
|
|
||||||
|
const updateScene = () => {
|
||||||
|
requestAnimationFrame(updateScene);
|
||||||
|
this.controls.update();
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateScene();
|
||||||
|
// Append canvas
|
||||||
|
viewer.appendChild(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error, isReady, isLoading } = this.state;
|
||||||
|
const loadingMessage = 'Loading 3D model.';
|
||||||
|
const showViewer = isReady && !error;
|
||||||
|
const showLoading = isLoading && !error;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{error && <LoadingScreen status={error} spinner={false} />}
|
||||||
|
{showLoading && <LoadingScreen status={loadingMessage} spinner />}
|
||||||
|
<div
|
||||||
|
style={{ opacity: showViewer ? 1 : 0 }}
|
||||||
|
className="three-viewer file-render__viewer"
|
||||||
|
ref={this.viewer}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThreeViewer;
|
|
@ -0,0 +1,10 @@
|
||||||
|
const detectWebGL = () => {
|
||||||
|
// Create canvas element.
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
// Get WebGLRenderingContext from canvas element.
|
||||||
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
|
// Return the result.
|
||||||
|
return gl && gl instanceof WebGLRenderingContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default detectWebGL;
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { LoadingManager, STLLoader, OBJLoader } from './three';
|
||||||
|
|
||||||
|
const Manager = ({ onLoad, onStart, onError }) => {
|
||||||
|
const manager = new LoadingManager();
|
||||||
|
manager.onLoad = onLoad;
|
||||||
|
manager.onStart = onStart;
|
||||||
|
manager.onError = onError;
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Loader = (fileType, manager) => {
|
||||||
|
const fileTypes = {
|
||||||
|
stl: () => new STLLoader(manager),
|
||||||
|
obj: () => new OBJLoader(manager),
|
||||||
|
};
|
||||||
|
return fileTypes[fileType] ? fileTypes[fileType]() : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThreeLoader = ({ fileType, filePath }, renderModel, managerEvents) => {
|
||||||
|
if (fileType) {
|
||||||
|
const manager = Manager(managerEvents);
|
||||||
|
const loader = Loader(fileType, manager);
|
||||||
|
|
||||||
|
if (loader) {
|
||||||
|
loader.load(filePath, data => {
|
||||||
|
renderModel(fileType, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreeLoader;
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { WebGLRenderer } from './three';
|
||||||
|
|
||||||
|
const ThreeRenderer = ({ antialias, shadowMap }) => {
|
||||||
|
const renderer = new WebGLRenderer({
|
||||||
|
antialias,
|
||||||
|
});
|
||||||
|
// Renderer configuration
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.gammaInput = true;
|
||||||
|
renderer.gammaOutput = true;
|
||||||
|
renderer.shadowMap.enabled = shadowMap;
|
||||||
|
return renderer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreeRenderer;
|
57
src/renderer/component/viewers/threeViewer/internal/scene.js
Normal file
57
src/renderer/component/viewers/threeViewer/internal/scene.js
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import * as THREE from './three';
|
||||||
|
|
||||||
|
const addGrid = (scene, { gridColor, centerLineColor, size }) => {
|
||||||
|
const divisions = size / 2;
|
||||||
|
const grid = new THREE.GridHelper(
|
||||||
|
size,
|
||||||
|
divisions,
|
||||||
|
new THREE.Color(centerLineColor),
|
||||||
|
new THREE.Color(gridColor)
|
||||||
|
);
|
||||||
|
grid.material.opacity = 0.4;
|
||||||
|
grid.material.transparent = true;
|
||||||
|
scene.add(grid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addLights = (scene, color, groundColor) => {
|
||||||
|
// Light color
|
||||||
|
const lightColor = new THREE.Color(color);
|
||||||
|
// Main light
|
||||||
|
const light = new THREE.HemisphereLight(lightColor, groundColor, 0.4);
|
||||||
|
// Shadow light
|
||||||
|
const shadowLight = new THREE.DirectionalLight(lightColor, 0.4);
|
||||||
|
shadowLight.position.set(100, 50, 100);
|
||||||
|
// Back light
|
||||||
|
const backLight = new THREE.DirectionalLight(lightColor, 0.6);
|
||||||
|
backLight.position.set(-100, 200, 50);
|
||||||
|
// Add lights to scene
|
||||||
|
scene.add(backLight);
|
||||||
|
scene.add(light);
|
||||||
|
scene.add(shadowLight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Scene = ({ backgroundColor, groundColor, showFog, showGrid, gridColor, centerLineColor }) => {
|
||||||
|
// Convert color
|
||||||
|
const bgColor = new THREE.Color(backgroundColor);
|
||||||
|
// New scene
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
// Background color
|
||||||
|
scene.background = bgColor;
|
||||||
|
// Fog effect
|
||||||
|
scene.fog = showFog === true ? new THREE.Fog(bgColor, 1, 95) : null;
|
||||||
|
// Add grid
|
||||||
|
if (showGrid) {
|
||||||
|
addGrid(scene, {
|
||||||
|
size: 100,
|
||||||
|
gridColor,
|
||||||
|
centerLineColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Add basic lights
|
||||||
|
addLights(scene, '#FFFFFF', groundColor);
|
||||||
|
|
||||||
|
// Return new three scene
|
||||||
|
return scene;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Scene;
|
10
src/renderer/component/viewers/threeViewer/internal/three.js
Normal file
10
src/renderer/component/viewers/threeViewer/internal/three.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// Currently it's not possible to import the files within the "examples/js" directory.
|
||||||
|
// Fix: https://github.com/mrdoob/three.js/issues/9562#issuecomment-383390251
|
||||||
|
global.THREE = THREE;
|
||||||
|
require('three/examples/js/controls/OrbitControls');
|
||||||
|
require('three/examples/js/loaders/OBJLoader');
|
||||||
|
require('three/examples/js/loaders/STLLoader');
|
||||||
|
|
||||||
|
module.exports = global.THREE;
|
|
@ -8940,6 +8940,10 @@ text-table@~0.2.0:
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
|
||||||
|
|
||||||
|
three@^0.93.0:
|
||||||
|
version "0.93.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/three/-/three-0.93.0.tgz#3fd6c367ef4554abbb6e16ad69936283e895c123"
|
||||||
|
|
||||||
throttleit@0.0.2:
|
throttleit@0.0.2:
|
||||||
version "0.0.2"
|
version "0.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf"
|
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf"
|
||||||
|
|
Loading…
Reference in a new issue