├── .gitignore ├── .babelrc ├── .prettierrc ├── src ├── lib │ ├── isIFrame.js │ ├── parseQueryString.js │ └── Colors.js ├── index.js ├── views │ ├── VisibleOption.js │ ├── SelectOption.js │ ├── TextureOption.js │ └── ColorOption.js ├── OptionsView.js ├── Options.js ├── Configurator.js ├── schema.json └── Viewer.js ├── app.js ├── webpack.config.js ├── package.json ├── index.html ├── samples ├── car.json ├── chair.json ├── robot.json └── orb.json ├── styles.css ├── README.md └── dist └── SketchfabConfigurator-1.0.2.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "bracketSpacing": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/isIFrame.js: -------------------------------------------------------------------------------- 1 | export default function isIFrame() { 2 | try { 3 | return window.self !== window.top; 4 | } catch (e) { 5 | return true; 6 | } 7 | } -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var iframeEl = document.getElementById('api-frame'); 2 | var optionsEl = document.querySelector('.options'); 3 | var configurator = new SketchfabConfigurator.Configurator(iframeEl, optionsEl); 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | 3 | import Configurator from './Configurator'; 4 | import Viewer from './Viewer'; 5 | import Options from './Options'; 6 | import OptionsView from './OptionsView'; 7 | 8 | export { Configurator, Viewer, Options, OptionsView }; 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const package = require('./package.json'); 2 | const path = require('path'); 3 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 4 | 5 | module.exports = { 6 | entry: ['./src/index.js'], 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: `SketchfabConfigurator-${package.version}.js`, 10 | library: 'SketchfabConfigurator' 11 | }, 12 | module: { 13 | rules: [{ test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader' }] 14 | }, 15 | // plugins: [new BundleAnalyzerPlugin()] 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/parseQueryString.js: -------------------------------------------------------------------------------- 1 | /** 2 | * https://gist.github.com/Manc/9409355 3 | * Convert a URL or just the query part of a URL to an 4 | * object with all keys and values. 5 | * Usage examples: 6 | * // Get "GET" parameters from current request as object: 7 | * var parameters = parseQueryString(window.location.search); 8 | */ 9 | export default function parseQueryString(query) { 10 | var obj = {}, 11 | qPos = query.indexOf('?'), 12 | tokens = query.substr(qPos + 1).split('&'), 13 | i = tokens.length - 1; 14 | if (qPos !== -1 || query.indexOf('=') !== -1) { 15 | for (; i >= 0; i--) { 16 | var s = tokens[i].split('='); 17 | obj[unescape(s[0])] = s.hasOwnProperty(1) ? unescape(s[1]) : null; 18 | } 19 | } 20 | return obj; 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "configurator", 3 | "version": "1.0.6", 4 | "description": "Configurator demo", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "watch": "webpack --watch --mode=development", 8 | "build": "webpack --mode=production", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "version": "npm run build && git add -A dist", 11 | "docs": "npx jsdoc src/*.js src/**/*.js -d docs" 12 | }, 13 | "author": "Sketchfab", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "babel-core": "^6.26.0", 17 | "babel-loader": "^7.1.4", 18 | "babel-preset-env": "^1.6.1", 19 | "webpack": "^4.4.1", 20 | "webpack-bundle-analyzer": "^2.11.1", 21 | "webpack-cli": "^2.0.13" 22 | }, 23 | "dependencies": { 24 | "ajv": "^6.4.0", 25 | "babel-polyfill": "^6.26.0", 26 | "mustache": "^2.3.0", 27 | "unfetch": "^3.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sketchfab Configurator 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 |
17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /samples/car.json: -------------------------------------------------------------------------------- 1 | { 2 | "model": "36e9d3598c554bb69f3d9cd00e161818", 3 | "config": [ 4 | { 5 | "name": "Paint color", 6 | "type": "color", 7 | "material": ["body", "body_0"], 8 | "default": "#790300" 9 | }, 10 | { 11 | "name": "Seat color", 12 | "type": "color", 13 | "material": ["leather_1", "leather_2"], 14 | "options": [ 15 | { 16 | "color": "#333333", 17 | "name": "Black" 18 | }, 19 | { 20 | "color": "#FFFFFF", 21 | "name": "White" 22 | }, 23 | { 24 | "color": "#803A00", 25 | "name": "Brown" 26 | } 27 | ], 28 | "default": "#803A00" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /samples/chair.json: -------------------------------------------------------------------------------- 1 | { 2 | "model": "4d9d1eaa3efe44099a223192d418c17d", 3 | "params": { 4 | "camera": 0, 5 | "preload": 1, 6 | "ui_controls": 0, 7 | "ui_infos": 0, 8 | "ui_hint": 2, 9 | "ui_stop": 0, 10 | "internal": 1, 11 | "watermark": 0, 12 | "double_click": 0, 13 | "autospin": -0.1 14 | }, 15 | "config": [ 16 | { 17 | "name": "Select color", 18 | "type": "color", 19 | "material": ["seat"], 20 | "options": [ 21 | { 22 | "color": "#6FC0E7", 23 | "name": "Light Blue" 24 | }, 25 | { 26 | "color": "#2B4462", 27 | "name": "Dark Blue" 28 | }, 29 | { 30 | "color": "#80B744", 31 | "name": "Green" 32 | }, 33 | { 34 | "color": "#cc1f16", 35 | "name": "Red" 36 | } 37 | ], 38 | "default": "#2B4462" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/Colors.js: -------------------------------------------------------------------------------- 1 | const GAMMA = 2.4; 2 | 3 | function srgbToLinear1(c) { 4 | var v = 0.0; 5 | if (c < 0.04045) { 6 | if (c >= 0.0) v = c * (1.0 / 12.92); 7 | } else { 8 | v = Math.pow((c + 0.055) * (1.0 / 1.055), GAMMA); 9 | } 10 | return v; 11 | } 12 | 13 | function srgbToLinear(c, out) { 14 | var col = out || new Array(c.length); 15 | 16 | if (c.length > 2 && c.length < 5) { 17 | col[0] = srgbToLinear1(c[0]); 18 | col[1] = srgbToLinear1(c[1]); 19 | col[2] = srgbToLinear1(c[2]); 20 | if (col.length > 3 && c.length > 3) col[3] = c[3]; 21 | } else { 22 | throw new Error('Invalid color. Expected 3 or 4 components, but got ' + c.length); 23 | } 24 | return col; 25 | } 26 | 27 | function hexToRgb(hexColor) { 28 | var m = hexColor.match(/^#([0-9a-f]{6})$/i); 29 | if (m) { 30 | return [ 31 | parseInt(m[1].substr(0, 2), 16) / 255, 32 | parseInt(m[1].substr(2, 2), 16) / 255, 33 | parseInt(m[1].substr(4, 2), 16) / 255 34 | ]; 35 | } else { 36 | throw new Error('Invalid color: ' + hexColor); 37 | } 38 | } 39 | 40 | export { 41 | srgbToLinear, 42 | hexToRgb 43 | }; 44 | -------------------------------------------------------------------------------- /samples/robot.json: -------------------------------------------------------------------------------- 1 | { 2 | "model": "58425893f23d4ee691014e43fa12980c", 3 | "params": { 4 | "camera": 0, 5 | "preload": 1, 6 | "animation_autoplay": 0 7 | }, 8 | "config": [ 9 | { 10 | "name": "Arms", 11 | "type": "visible", 12 | "selector": "[name=\"g_arm\"]", 13 | "default": false 14 | }, 15 | 16 | { 17 | "name": "Plastic color", 18 | "type": "color", 19 | "material": "white_plastic", 20 | "default": "#1CAAD9" 21 | }, 22 | 23 | { 24 | "name": "Antenna side", 25 | "type": "select", 26 | "options": [ 27 | { 28 | "selector": "", 29 | "name": "No antenna" 30 | }, 31 | { 32 | "selector": "[name=\"g_head_LEFT\"] [name=\"g_aerial\"]", 33 | "name": "Left" 34 | }, 35 | { 36 | "selector": "[name=\"g_head_RIGHT\"] [name=\"g_aerial\"]", 37 | "name": "Right" 38 | } 39 | ], 40 | "default": 0 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/views/VisibleOption.js: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | 3 | /** 4 | * View for 'visible' option 5 | */ 6 | class VisibleOption { 7 | /** 8 | * @param {object} model Options instance 9 | * @param {number} index Index of option 10 | */ 11 | constructor(model, index) { 12 | this.model = model; 13 | this.index = index; 14 | } 15 | 16 | _generateId() { 17 | return 'control_' + Math.floor(Math.random() * 10000); 18 | } 19 | 20 | /** 21 | * Renders the view 22 | */ 23 | render() { 24 | if (!this.el) { 25 | this.el = document.createElement('DIV'); 26 | this.el.className = 'option option--visible'; 27 | var value = this.model.getOptionValue(this.index); 28 | var html = Mustache.render(this.template, { 29 | id: this._generateId(), 30 | index: this.index, 31 | option: this.model.options[this.index], 32 | value: value 33 | }); 34 | this.el.innerHTML = html; 35 | } 36 | return this; 37 | } 38 | } 39 | 40 | VisibleOption.prototype.template = ` 41 | 42 |
43 | 44 |
45 | `; 46 | 47 | export default VisibleOption; 48 | -------------------------------------------------------------------------------- /src/views/SelectOption.js: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | 3 | /** 4 | * View for 'select' option 5 | */ 6 | class SelectOption { 7 | /** 8 | * @param {object} model Options instance 9 | * @param {number} index Index of option 10 | */ 11 | constructor(model, index) { 12 | this.model = model; 13 | this.index = index; 14 | } 15 | 16 | _generateId() { 17 | return 'control_' + Math.floor(Math.random() * 10000); 18 | } 19 | 20 | /** 21 | * Renders the view 22 | */ 23 | render() { 24 | if (!this.el) { 25 | this.el = document.createElement('DIV'); 26 | this.el.className = 'option option--select'; 27 | 28 | var currentValue = this.model.getOptionValue(this.index); 29 | var optionsWithIndex = this.model.options[this.index].options.map((opt, index) => { 30 | return Object.assign({}, opt, { 31 | index: index, 32 | isSelected: index === currentValue 33 | }); 34 | }); 35 | 36 | var html = Mustache.render(this.template, { 37 | id: this._generateId(), 38 | index: this.index, 39 | option: this.model.options[this.index], 40 | options: optionsWithIndex, 41 | value: currentValue 42 | }); 43 | this.el.innerHTML = html; 44 | } 45 | return this; 46 | } 47 | } 48 | 49 | SelectOption.prototype.template = ` 50 | 51 |
52 | 57 |
58 | `; 59 | 60 | export default SelectOption; 61 | -------------------------------------------------------------------------------- /src/views/TextureOption.js: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | 3 | /** 4 | * View for 'texture' option 5 | */ 6 | class TextureOption { 7 | /** 8 | * @param {object} model Options instance 9 | * @param {number} index Index of option 10 | */ 11 | constructor(model, index) { 12 | this.model = model; 13 | this.index = index; 14 | } 15 | 16 | _generateId() { 17 | return 'control_' + Math.floor(Math.random() * 10000); 18 | } 19 | 20 | /** 21 | * Renders the view 22 | */ 23 | render() { 24 | if (!this.el) { 25 | this.el = document.createElement('DIV'); 26 | this.el.className = 'option option--texture'; 27 | 28 | var currentValue = this.model.getOptionValue(this.index); 29 | var optionsWithIndex = this.model.options[this.index].options.map((opt, index) => { 30 | return Object.assign({}, opt, { 31 | currentIndex: index, 32 | isSelected: index === currentValue 33 | }); 34 | }); 35 | 36 | var html = Mustache.render(this.template, { 37 | id: this._generateId(), 38 | index: this.index, 39 | option: this.model.options[this.index], 40 | options: optionsWithIndex, 41 | value: currentValue 42 | }); 43 | this.el.innerHTML = html; 44 | } 45 | return this; 46 | } 47 | } 48 | 49 | TextureOption.prototype.template = ` 50 | 51 |
52 | {{#options}} 53 | 60 | {{/options}} 61 |
62 | `; 63 | 64 | export default TextureOption; 65 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | @import 'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.0/normalize.min.css'; 2 | 3 | html, 4 | body { 5 | margin: 0; 6 | padding: 0; 7 | width: 100%; 8 | height: 100%; 9 | font-family: sans-serif; 10 | } 11 | 12 | iframe { 13 | display: block; 14 | border: 0; 15 | width: 100%; 16 | height: 100%; 17 | } 18 | 19 | .app { 20 | display: flex; 21 | width: 100%; 22 | height: 100%; 23 | } 24 | 25 | #api-frame { 26 | flex: 1 1 auto; 27 | } 28 | 29 | .options { 30 | box-sizing: border-box; 31 | flex: 0 0 300px; 32 | padding: 30px; 33 | overflow: auto; 34 | } 35 | 36 | .option { 37 | display: flex; 38 | padding: 16px 0; 39 | border-bottom: 1px solid #ccc; 40 | } 41 | 42 | .option:first-child { 43 | border-top: 1px solid #ccc; 44 | } 45 | 46 | .option label { 47 | flex: 0 0 130px; 48 | } 49 | 50 | .color { 51 | display: flex; 52 | align-items: center; 53 | padding: 4px 0; 54 | } 55 | 56 | .color input[type='radio'] { 57 | position: absolute; 58 | opacity: 0; 59 | pointer-events: none; 60 | } 61 | 62 | .color__swatch { 63 | display: inline-block; 64 | width: 24px; 65 | height: 24px; 66 | border-radius: 50%; 67 | border: 1px solid rgba(0, 0, 0, 0.5); 68 | margin-right: 4px; 69 | } 70 | 71 | .color input:checked + .color__swatch { 72 | box-shadow: 0 0 0 2px #000; 73 | } 74 | 75 | .color:hover .color__swatch { 76 | box-shadow: 0 0 0 2px #999; 77 | } 78 | 79 | .texture { 80 | display: block; 81 | position: relative; 82 | margin-bottom: 10px; 83 | } 84 | 85 | .texture input[type='radio'] { 86 | position: absolute; 87 | opacity: 0; 88 | pointer-events: none; 89 | } 90 | 91 | .texture__preview { 92 | display: block; 93 | } 94 | 95 | .texture__preview img { 96 | display: block; 97 | max-width: 100%; 98 | height: auto; 99 | } 100 | 101 | .texture input:checked + .texture__preview > img { 102 | box-shadow: 0 0 0 2px #000; 103 | } 104 | 105 | .texture__name { 106 | display: block; 107 | margin-top: 4px; 108 | } 109 | -------------------------------------------------------------------------------- /src/views/ColorOption.js: -------------------------------------------------------------------------------- 1 | import Mustache from 'mustache'; 2 | 3 | /** 4 | * View for 'color' option 5 | */ 6 | class ColorOption { 7 | /** 8 | * @param {object} model Options instance 9 | * @param {number} index Index of option 10 | */ 11 | constructor(model, index) { 12 | this.model = model; 13 | this.index = index; 14 | } 15 | 16 | _generateId() { 17 | return 'control_' + Math.floor(Math.random() * 10000); 18 | } 19 | 20 | /** 21 | * Renders the view 22 | */ 23 | render() { 24 | if (!this.el) { 25 | this.el = document.createElement('DIV'); 26 | this.el.className = 'option option--color'; 27 | 28 | const currentValue = this.model.getOptionValue(this.index); 29 | let optionsForTemplate; 30 | if (this.model.options[this.index].options !== undefined) { 31 | optionsForTemplate = this.model.options[this.index].options.map((opt, index) => { 32 | return Object.assign({}, opt, { 33 | isSelected: opt.color.toUpperCase() === currentValue.toUpperCase() 34 | }); 35 | }); 36 | } else { 37 | optionsForTemplate = null; 38 | } 39 | 40 | const html = Mustache.render(this.template, { 41 | id: this._generateId(), 42 | index: this.index, 43 | option: this.model.options[this.index], 44 | options: optionsForTemplate, 45 | value: this.model.getOptionValue(this.index) 46 | }); 47 | this.el.innerHTML = html; 48 | } 49 | return this; 50 | } 51 | } 52 | 53 | ColorOption.prototype.template = ` 54 | 55 |
56 | {{#options}} 57 | 62 | {{/options}} 63 | {{^options}} 64 | 65 | {{/options}} 66 |
67 | `; 68 | 69 | export default ColorOption; 70 | -------------------------------------------------------------------------------- /src/OptionsView.js: -------------------------------------------------------------------------------- 1 | import ColorOption from './views/ColorOption'; 2 | import SelectOption from './views/SelectOption'; 3 | import TextureOption from './views/TextureOption'; 4 | import VisibleOption from './views/VisibleOption'; 5 | 6 | const FORM_CLASS_NAME = 'sketchfab-configurator'; 7 | 8 | /** 9 | * View for the configurator UI. 10 | * 11 | * The OptionsView will create subviews for each option and listen for all `change` events. 12 | */ 13 | class OptionsView { 14 | /** 15 | * 16 | * @param {object} el Placeholder DOM element for the UI 17 | * @param {object} model Options instance 18 | */ 19 | constructor(el, model) { 20 | this.el = el; 21 | this.model = model; 22 | this.subviews = []; 23 | this.isRendered = false; 24 | this._handleOptionChange = this._handleOptionChange.bind(this); 25 | this.el.addEventListener('change', this._handleOptionChange, false); 26 | this.render(); 27 | } 28 | 29 | /** 30 | * Renders the view 31 | */ 32 | render() { 33 | if (this.isRendered) { 34 | return; 35 | } 36 | 37 | this.formEl = document.createElement('form'); 38 | this.formEl.className = FORM_CLASS_NAME; 39 | this.el.appendChild(this.formEl); 40 | 41 | const classes = { 42 | color: ColorOption, 43 | visible: VisibleOption, 44 | select: SelectOption, 45 | texture: TextureOption 46 | }; 47 | 48 | var subview; 49 | for (var i = 0, l = this.model.options.length; i < l; i++) { 50 | subview = new classes[this.model.options[i].type](this.model, i); 51 | subview.render(); 52 | this.subviews.push(subview); 53 | this.formEl.appendChild(subview.el); 54 | } 55 | 56 | this.isRendered = true; 57 | } 58 | 59 | /** 60 | * Disposes the view 61 | * Removes event listeners and empties the DOM element. 62 | */ 63 | dispose() { 64 | this.formEl.removeEventListener('change', this._handleOptionChange, false); 65 | this.el.innerHTML = ''; 66 | this.subviews = []; 67 | this.isRendered = false; 68 | } 69 | 70 | _handleOptionChange(e) { 71 | e.preventDefault(); 72 | const target = e.target; 73 | const optionIndex = parseInt(target.getAttribute('data-option'), 10); 74 | const value = target.type === 'checkbox' ? !!target.checked : target.value; 75 | this.model.setOptionValue(optionIndex, value); 76 | } 77 | } 78 | 79 | export default OptionsView; 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Configurator Framework 2 | 3 | The Configurator Framework is a javascript library for building 3D configurators with Sketchfab Viewer API. 4 | Configurators are described by a JSON file that are consummed by the framework. 5 | 6 | ## Supported option types 7 | 8 | ### Color 9 | 10 | The `color` option allows the user to change the color of a material. 11 | The selected color is applied to Diffuse, Diffuse (PBR) and Albedo (PBR) channels. 12 | 13 | ```json 14 | { 15 | "name": "Plastic color", 16 | "type": "color", 17 | "material": "white_plastic", 18 | "default": "#1CAAD9" 19 | }, 20 | ``` 21 | 22 | You can also include a list of predefined colors: 23 | 24 | ```json 25 | { 26 | "name": "Seat color", 27 | "type": "color", 28 | "material": ["leather_1", "leather_2"], 29 | "options": [ 30 | { 31 | "color": "#333333", 32 | "name": "Black" 33 | }, 34 | { 35 | "color": "#FFFFFF", 36 | "name": "White" 37 | }, 38 | { 39 | "color": "#803A00", 40 | "name": "Brown" 41 | } 42 | ], 43 | "default": "#803A00" 44 | } 45 | ``` 46 | 47 | ### Visible 48 | 49 | The `visible` option allows the user to show and hide an object. 50 | 51 | ```json 52 | { 53 | "name": "Arms", 54 | "type": "visible", 55 | "selector": "[name=\"g_arm\"]", 56 | "default": false 57 | }, 58 | ``` 59 | 60 | ### Select 61 | 62 | The `select` option allows the user to select an object among a list of objects. 63 | Only the selected object will be visible. 64 | 65 | ```javascript 66 | { 67 | "name": "Antenna side", 68 | "type": "select", 69 | "options": [ 70 | { 71 | "selector": "", 72 | "name": "No antenna" 73 | }, 74 | { 75 | "selector": "[name=\"g_head_LEFT\"] [name=\"g_aerial\"]", 76 | "name": "Left" 77 | }, 78 | { 79 | "selector": "[name=\"g_head_RIGHT\"] [name=\"g_aerial\"]", 80 | "name": "Right" 81 | } 82 | ], 83 | "default": 0 84 | } 85 | ``` 86 | 87 | ### Texture 88 | 89 | The `texture` option allows the user to change the texture of selected channels of a material. 90 | Images must be CORS-enabled. 91 | 92 | ```javascript 93 | { 94 | "name": "Face", 95 | "type": "texture", 96 | "material": "face", 97 | "channels": ["AlbedoPBR", "EmitColor"], 98 | "options": [ 99 | { 100 | "name": "Default", 101 | "url": "https://example.com/face-default.png" 102 | }, 103 | { 104 | "name": "Happy", 105 | "url": "https://example.com/face-happy.jpg" 106 | }, 107 | { 108 | "name": "Sleepy", 109 | "url": "https://example.com/face-sleepy.jpg" 110 | } 111 | ], 112 | "default": 0 113 | } 114 | ``` 115 | 116 | ## API Documentation 117 | 118 | * `npm run docs` to generate the docs 119 | * Doc will be generated in `docs/` 120 | 121 | ## Development 122 | 123 | * `npm install` to install dependencies 124 | * `npm run watch` to build/watch for dev 125 | 126 | ## Building for production / Release 127 | 128 | * `git checkout master` 129 | * `npm install` to install dependencies. 130 | * `npm version x.x.x` where `x.x.x` is [a valid version](https://docs.npmjs.com/cli/version). 131 | * this will update the version in `package.json` 132 | * build the library for production (`npm run build`) 133 | * create a new commit 134 | * tag the commit with the new version 135 | * Push the tag to github 136 | 137 | ## Todo 138 | 139 | * [ ] Use material values as default values 140 | * [ ] Support textures or full materials 141 | * [ ] Support scale/translation/rotation 142 | * [ ] Custom color picker 143 | * [ ] UI Customization 144 | * [ ] Add presets for changing multiple properties at once 145 | * [ ] Interactivity? (trigger animation, move camera) 146 | -------------------------------------------------------------------------------- /src/Options.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Model for options 3 | */ 4 | class Options { 5 | /** 6 | * 7 | * @param {object[]} options List of options 8 | * @param {object} viewer Viewer instance 9 | */ 10 | constructor(options, viewer) { 11 | this.options = options; 12 | this.viewer = viewer; 13 | this.values = []; 14 | this.initialize(); 15 | } 16 | 17 | initialize() { 18 | for (var i = 0, l = this.options.length; i < l; i++) { 19 | switch (this.options[i].type) { 20 | case 'visible': 21 | this.setOptionValue(i, !!this.options[i].default); 22 | break; 23 | case 'color': 24 | this.setOptionValue(i, this.options[i].default); 25 | break; 26 | case 'select': 27 | if (this.options[i].default) { 28 | this.setOptionValue(i, this.options[i].default); 29 | } else { 30 | this.setOptionValue(i, 0); 31 | } 32 | break; 33 | case 'texture': 34 | this.setOptionValue(i, this.options[i].default); 35 | break; 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * Set an option value 42 | * @param {number} optionIndex Index of option 43 | * @param {*} value Value 44 | */ 45 | setOptionValue(optionIndex, value) { 46 | const option = this.options[optionIndex]; 47 | const fn = { 48 | color: 'applyOptionColor', 49 | visible: 'applyOptionVisible', 50 | select: 'applyOptionSelect', 51 | texture: 'applyOptionTexture' 52 | }; 53 | 54 | if (option.type === 'select' || option.type === 'texture') { 55 | value = parseInt(value, 10); 56 | } 57 | 58 | if (option.type === 'visible') { 59 | value = Boolean(value); 60 | } 61 | 62 | this.values[optionIndex] = value; 63 | this[fn[option.type]].apply(this, [optionIndex, value]); 64 | } 65 | 66 | /** 67 | * Gets the value of an option 68 | * @param {number} optionIndex Index of option 69 | */ 70 | getOptionValue(optionIndex) { 71 | return this.values[optionIndex]; 72 | } 73 | 74 | applyOptionColor(optionIndex, color) { 75 | const option = this.options[optionIndex]; 76 | 77 | if (option.type !== 'color') { 78 | throw new Error('Option is not of "color" type'); 79 | } 80 | 81 | const material = option.material; 82 | this.viewer.setColor(material, color); 83 | } 84 | 85 | applyOptionVisible(optionIndex, isVisible) { 86 | const option = this.options[optionIndex]; 87 | 88 | if (option.type !== 'visible') { 89 | throw new Error('Option is not of "visible" type'); 90 | } 91 | 92 | const selector = option.selector; 93 | if (isVisible) { 94 | this.viewer.show(selector); 95 | } else { 96 | this.viewer.hide(selector); 97 | } 98 | } 99 | 100 | applyOptionSelect(optionIndex, selectedIndex) { 101 | const option = this.options[optionIndex]; 102 | 103 | if (option.type !== 'select') { 104 | throw new Error('Option is not of "select" type'); 105 | } 106 | 107 | for (var i = 0, l = option.options.length; i < l; i++) { 108 | if (option.options[i].selector === '') { 109 | continue; 110 | } 111 | 112 | if (i === selectedIndex) { 113 | this.viewer.show(option.options[i].selector); 114 | } else { 115 | this.viewer.hide(option.options[i].selector); 116 | } 117 | } 118 | } 119 | 120 | applyOptionTexture(optionIndex, selectedIndex) { 121 | const option = this.options[optionIndex]; 122 | 123 | if (option.type !== 'texture') { 124 | throw new Error('Option is not of "texture" type'); 125 | } 126 | 127 | for (var i = 0, l = option.options.length; i < l; i++) { 128 | if (i === selectedIndex) { 129 | this.viewer.setTexture(option.material, option.channels, option.options[i].url); 130 | } 131 | } 132 | } 133 | } 134 | 135 | export default Options; 136 | -------------------------------------------------------------------------------- /src/Configurator.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import Ajv from 'ajv'; 3 | import fetch from 'unfetch'; 4 | import isIFrame from './lib/isIFrame'; 5 | import parseQueryString from './lib/parseQueryString'; 6 | import Viewer from './Viewer'; 7 | import Options from './Options'; 8 | import OptionsView from './OptionsView'; 9 | const schema = require('./schema.json'); 10 | 11 | const ALLOW_EMBED = true; 12 | 13 | /** 14 | * Main Configurator class 15 | */ 16 | class Configurator { 17 | /** 18 | * @param {object} iframeEl iframe DOM element for the viewer 19 | * @param {object} optionsEl Placeholder DOM element for the UI 20 | * @param {object} config Configuration object 21 | */ 22 | constructor(iframeEl, optionsEl, config = null) { 23 | this.iframeEl = iframeEl; 24 | this.optionsEl = optionsEl; 25 | this.optionView = null; 26 | this.viewer = null; 27 | this.config = config; 28 | 29 | if (!ALLOW_EMBED && isIFrame()) { 30 | this._renderFatalError('This page is for preview only and cannot be embedded.'); 31 | return; 32 | } 33 | 34 | let promiseConfig; 35 | 36 | if (this.config) { 37 | promiseConfig = Promise.resolve(this.config); 38 | } else { 39 | var parameters = parseQueryString(window.location.search); 40 | if (parameters.hasOwnProperty('config')) { 41 | console.log('Loading config from URL', parameters.config); 42 | promiseConfig = this._loadConfig(parameters.config); 43 | } else if (window.defaultConfigUrl) { 44 | console.log('Loading default config URL', window.defaultConfigUrl); 45 | promiseConfig = this._loadConfig(window.defaultConfigUrl); 46 | } else if (window.defaultConfig) { 47 | console.log('Loading config', window.defaultConfig); 48 | promiseConfig = Promise.resolve(window.defaultConfig); 49 | } else { 50 | promiseConfig = Promise.reject('No configuration found'); 51 | } 52 | } 53 | 54 | promiseConfig 55 | .then(config => { 56 | let validation = this._validate(config); 57 | if (validation.valid === false) { 58 | console.warn(validation.errors); 59 | } 60 | this.config = config; 61 | this._initialize(); 62 | }) 63 | .catch(error => { 64 | console.error(error); 65 | }); 66 | } 67 | 68 | _initialize() { 69 | const config = this.config; 70 | this.viewer = new Viewer( 71 | iframeEl, 72 | config.model, 73 | () => { 74 | this.optionView = new OptionsView( 75 | optionsEl, 76 | new Options(config.config, this.viewer) 77 | ); 78 | }, 79 | { 80 | params: config.params ? config.params : {} 81 | } 82 | ); 83 | } 84 | 85 | _renderFatalError(message) { 86 | const styles = ` 87 | position: absolute; 88 | top: 0; 89 | left: 0; 90 | width: 96%; 91 | padding: 2%; 92 | `; 93 | const out = `
${message}
`; 94 | const div = document.createElement('DIV'); 95 | div.innerHTML = out; 96 | document.body.appendChild(div); 97 | } 98 | 99 | _validate(config) { 100 | const ajv = new Ajv(); 101 | const validate = ajv.compile(schema); 102 | const valid = validate(config); 103 | if (valid) { 104 | return { 105 | valid: true 106 | }; 107 | } else { 108 | return { 109 | valid: false, 110 | errors: validate.errors 111 | }; 112 | } 113 | } 114 | 115 | /** 116 | * Disposes the configurator 117 | */ 118 | dispose() { 119 | this.optionView.dispose(); 120 | this.viewer.dispose(); 121 | } 122 | 123 | _loadConfig(url) { 124 | return fetch(url, { 125 | method: 'GET', 126 | mode: 'cors' 127 | }).then(function(response) { 128 | return response.json(); 129 | }); 130 | } 131 | } 132 | 133 | export default Configurator; 134 | -------------------------------------------------------------------------------- /samples/orb.json: -------------------------------------------------------------------------------- 1 | { 2 | "model": "d08045aeacdf487096a4b2cd1ed8845f", 3 | "params": { 4 | "camera": 0, 5 | "preload": 1, 6 | "ui_infos": 0, 7 | "ui_controls": 0, 8 | "watermark": 0, 9 | "ui_stop": 0, 10 | "autostart": 1 11 | }, 12 | "config": [ 13 | { 14 | "name": "Texture", 15 | "type": "texture", 16 | "material": "main_material", 17 | "channels": ["AlbedoPBR"], 18 | "options": [ 19 | { 20 | "name": "Yellow", 21 | "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1MS4yICg1NzUxOSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+eWVsbG93PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGRlZnM+PC9kZWZzPgogICAgPGcgaWQ9InllbGxvdyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZSIgZmlsbD0iI0ZGRkYwMCIgeD0iMCIgeT0iMCIgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiPjwvcmVjdD4KICAgICAgICA8dGV4dCBpZD0iWUVMTE9XIiBmb250LWZhbWlseT0iT3BlblNhbnMtRXh0cmFib2xkLCBPcGVuIFNhbnMiIGZvbnQtc2l6ZT0iMTgiIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiMwMDAwMDAiPgogICAgICAgICAgICA8dHNwYW4geD0iMjYuNTgzOTg0NCIgeT0iNzQiPllFTExPVzwvdHNwYW4+CiAgICAgICAgPC90ZXh0PgogICAgPC9nPgo8L3N2Zz4=" 22 | }, 23 | { 24 | "name": "Magenta", 25 | "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1MS4yICg1NzUxOSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+bWFnZW50YTwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJtYWdlbnRhIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlIiBmaWxsPSIjRkYwMEZGIiB4PSIwIiB5PSIwIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+PC9yZWN0PgogICAgICAgIDx0ZXh0IGlkPSJNQUdFTlRBIiBmb250LWZhbWlseT0iT3BlblNhbnMtRXh0cmFib2xkLCBPcGVuIFNhbnMiIGZvbnQtc2l6ZT0iMTgiIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiMwMDAwMDAiPgogICAgICAgICAgICA8dHNwYW4geD0iMTguMzA0Njg3NSIgeT0iNzQiPk1BR0VOVEE8L3RzcGFuPgogICAgICAgIDwvdGV4dD4KICAgIDwvZz4KPC9zdmc+" 26 | }, 27 | { 28 | "name": "Cyan", 29 | "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1MS4yICg1NzUxOSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+Y3lhbjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJjeWFuIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlIiBmaWxsPSIjMDBGRkZGIiB4PSIwIiB5PSIwIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+PC9yZWN0PgogICAgICAgIDx0ZXh0IGlkPSJDWUFOIiBmb250LWZhbWlseT0iT3BlblNhbnMtRXh0cmFib2xkLCBPcGVuIFNhbnMiIGZvbnQtc2l6ZT0iMTgiIGZvbnQtd2VpZ2h0PSI2MDAiIGZpbGw9IiMwMDAwMDAiPgogICAgICAgICAgICA8dHNwYW4geD0iMzguNjQyNTc4MSIgeT0iNzQiPkNZQU48L3RzcGFuPgogICAgICAgIDwvdGV4dD4KICAgIDwvZz4KPC9zdmc+" 30 | }, 31 | { 32 | "name": "Test", 33 | "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1MS4yICg1NzUxOSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+dGVzdDwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJ0ZXN0IiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLTIiIGZpbGw9IiNGRjAwMDAiIHg9IjAiIHk9IjAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxMjgiPjwvcmVjdD4KICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLTItQ29weSIgZmlsbD0iI0ZGRkYwMCIgeD0iMTYiIHk9IjAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxMjgiPjwvcmVjdD4KICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLTItQ29weS0yIiBmaWxsPSIjMDBGRjAwIiB4PSIzMiIgeT0iMCIgd2lkdGg9IjE2IiBoZWlnaHQ9IjEyOCI+PC9yZWN0PgogICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtMi1Db3B5LTMiIGZpbGw9IiMwMEZGRkYiIHg9IjQ4IiB5PSIwIiB3aWR0aD0iMTYiIGhlaWdodD0iMTI4Ij48L3JlY3Q+CiAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZS0yLUNvcHktNCIgZmlsbD0iIzAwMDBGRiIgeD0iNjQiIHk9IjAiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxMjgiPjwvcmVjdD4KICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLTItQ29weS01IiBmaWxsPSIjRkYwMEZGIiB4PSI4MCIgeT0iMCIgd2lkdGg9IjE2IiBoZWlnaHQ9IjEyOCI+PC9yZWN0PgogICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtMi1Db3B5LTYiIGZpbGw9IiNGRkZGRkYiIHg9Ijk2IiB5PSIwIiB3aWR0aD0iMTYiIGhlaWdodD0iMTI4Ij48L3JlY3Q+CiAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZS0yLUNvcHktNyIgZmlsbD0iIzAwMDAwMCIgeD0iMTEyIiB5PSIwIiB3aWR0aD0iMTYiIGhlaWdodD0iMTI4Ij48L3JlY3Q+CiAgICA8L2c+Cjwvc3ZnPg==" 34 | } 35 | ], 36 | "default": 0 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Configurator", 4 | "description": "A configurator configuration", 5 | "type": "object", 6 | "properties": { 7 | "model": { 8 | "description": "URLID of a Sketchfab model", 9 | "type": "string" 10 | }, 11 | "params": { 12 | "description": "Embed parameters", 13 | "type": "object" 14 | }, 15 | "config": { 16 | "description": "Description of what can be configured", 17 | "type": "array", 18 | "items": { 19 | "anyOf": [ 20 | { "$ref": "#/definitions/visibleOption" }, 21 | { "$ref": "#/definitions/colorOption" }, 22 | { "$ref": "#/definitions/selectOption" }, 23 | { "$ref": "#/definitions/textureOption" } 24 | ] 25 | } 26 | }, 27 | "extra": { 28 | "description": "Extra data for extending the format", 29 | "type": "object" 30 | } 31 | }, 32 | "required": ["model", "config"], 33 | "definitions": { 34 | "namedColor": { 35 | "$id": "#namedColor", 36 | "type": "object", 37 | "properties": { 38 | "name": { 39 | "description": "Human readable name", 40 | "type": "string" 41 | }, 42 | "color": { 43 | "description": "Hexadecimal RGB color", 44 | "type": "string" 45 | } 46 | }, 47 | "required": ["name", "color"] 48 | }, 49 | 50 | "predefinedTexture": { 51 | "$id": "#predefinedTexture", 52 | "type": "object", 53 | "properties": { 54 | "name": { 55 | "description": "Human readable name", 56 | "type": "string" 57 | }, 58 | "url": { 59 | "description": "Image URL", 60 | "type": "string" 61 | } 62 | }, 63 | "required": ["name", "url"] 64 | }, 65 | 66 | "modelPart": { 67 | "$id": "#modelPart", 68 | "type": "object", 69 | "properties": { 70 | "name": { 71 | "description": "Human readable name", 72 | "type": "string" 73 | }, 74 | "selector": { 75 | "description": "CSS selector", 76 | "$ref": "#/definitions/selector" 77 | } 78 | }, 79 | "required": ["name", "selector"] 80 | }, 81 | 82 | "selector": { 83 | "$id": "#selector", 84 | "type": ["string", "array"], 85 | "items": { 86 | "type": "string" 87 | } 88 | }, 89 | 90 | "visibleOption": { 91 | "$id": "#visibleOption", 92 | "type": "object", 93 | "properties": { 94 | "name": { 95 | "description": "Human readable option name", 96 | "type": "string" 97 | }, 98 | "type": { 99 | "description": "Type of option", 100 | "type": "string", 101 | "enum": ["visible"] 102 | }, 103 | "selector": { 104 | "description": "CSS selector", 105 | "$ref": "#/definitions/selector" 106 | }, 107 | "default": { 108 | "description": "Default value", 109 | "type": "boolean" 110 | } 111 | }, 112 | "required": ["name", "type", "selector"] 113 | }, 114 | 115 | "colorOption": { 116 | "$id": "#colorOption", 117 | "type": "object", 118 | "properties": { 119 | "name": { 120 | "description": "Human readable option name", 121 | "type": "string" 122 | }, 123 | "type": { 124 | "description": "Type of option", 125 | "type": "string", 126 | "enum": ["color"] 127 | }, 128 | "material": { 129 | "description": "Material name", 130 | "type": ["string", "array"] 131 | }, 132 | "options": { 133 | "description": "List of predefined colors", 134 | "type": "array", 135 | "items": { "$ref": "#/definitions/namedColor" } 136 | }, 137 | "default": { 138 | "description": "Default color (hexadecimal RGB)", 139 | "type": "string" 140 | } 141 | }, 142 | "required": ["name", "type", "material"] 143 | }, 144 | 145 | "selectOption": { 146 | "$id": "#selectOption", 147 | "type": "object", 148 | "properties": { 149 | "name": { 150 | "description": "Human readable option name", 151 | "type": "string" 152 | }, 153 | "type": { 154 | "description": "Type of option", 155 | "type": "string", 156 | "enum": ["select"] 157 | }, 158 | "options": { 159 | "description": "List of predefined model parts", 160 | "type": "array", 161 | "items": { 162 | "anyOf": [{ "$ref": "#/definitions/modelPart" }] 163 | } 164 | }, 165 | "default": { 166 | "description": "Default value index", 167 | "type": "integer" 168 | } 169 | }, 170 | "required": ["name", "type"] 171 | }, 172 | 173 | "textureOption": { 174 | "$id": "#textureOption", 175 | "type": "object", 176 | "properties": { 177 | "name": { 178 | "description": "Human readable option name", 179 | "type": "string" 180 | }, 181 | "type": { 182 | "description": "Type of option", 183 | "type": "string", 184 | "enum": ["texture"] 185 | }, 186 | "material": { 187 | "description": "Material name", 188 | "type": ["string", "array"] 189 | }, 190 | "channels": { 191 | "description": "Material channels", 192 | "type": ["string", "array"] 193 | }, 194 | "options": { 195 | "description": "List of predefined textures", 196 | "type": "array", 197 | "items": { 198 | "anyOf": [{ "$ref": "#/definitions/predefinedTexture" }] 199 | } 200 | }, 201 | "default": { 202 | "description": "Default value index", 203 | "type": "integer" 204 | } 205 | }, 206 | "required": ["name", "type"] 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Viewer.js: -------------------------------------------------------------------------------- 1 | import { srgbToLinear, hexToRgb } from './lib/Colors'; 2 | 3 | /** 4 | * View for the Viewer 5 | */ 6 | class Viewer { 7 | /** 8 | * @param {object} iframe iframe DOM element for the viewer 9 | * @param {string} uid Model UID 10 | * @param {function} callback Callback for when the viewer is ready 11 | * @param {object} options Options 12 | * @param {object} options.params Embed params for the viewer 13 | */ 14 | constructor(iframe, uid, callback, options) { 15 | this.iframe = iframe; 16 | this.uid = uid; 17 | this.callback = callback; 18 | this.options = options ? options : {}; 19 | this.api = null; 20 | this.doc = null; 21 | this.materials = null; 22 | this.textures = {}; 23 | this.start(); 24 | } 25 | 26 | /** 27 | * Starts the viewer 28 | */ 29 | start() { 30 | const client = new Sketchfab(this.iframe); 31 | 32 | const defaultParams = { 33 | graph_optimizer: 0 34 | }; 35 | const userParams = this.options.hasOwnProperty('params') ? this.options.params : {}; 36 | const params = Object.assign({}, defaultParams, userParams, { 37 | success: this._onSuccess.bind(this), 38 | error: this._onError.bind(this) 39 | }); 40 | client.init(this.uid, params); 41 | } 42 | 43 | /** 44 | * Disposes the viewer 45 | */ 46 | dispose() { 47 | this.materials = null; 48 | this.doc = null; 49 | this.api = null; 50 | this.callback = null; 51 | this.uid = null; 52 | this.iframe.src = 'about:blank'; 53 | this.iframe.className = this.iframe.className 54 | .replace('js-ready', '') 55 | .replace('js-started', ''); 56 | this.iframe = null; 57 | } 58 | 59 | /** 60 | * Returns the api 61 | * @return {Object} api 62 | */ 63 | getApi() { 64 | return this.api; 65 | } 66 | 67 | _onSuccess(api) { 68 | this.api = api; 69 | api.start(() => { 70 | this.iframe.className += ' js-started'; 71 | }); 72 | api.addEventListener( 73 | 'viewerready', 74 | function() { 75 | this._onViewerReady() 76 | .then( 77 | function() { 78 | console.log('Viewer ready'); 79 | this.iframe.className += ' js-ready'; 80 | this.callback(); 81 | }.bind(this) 82 | ) 83 | .catch(function(error) { 84 | console.error(error); 85 | }); 86 | }.bind(this) 87 | ); 88 | } 89 | 90 | _onError() { 91 | console.error('Viewer error'); 92 | } 93 | 94 | _onViewerReady() { 95 | const promises = [this._getGraph(), this._getMaterials()]; 96 | return Promise.all(promises) 97 | .then( 98 | function(results) { 99 | this.doc = results[0]; 100 | this.materials = results[1]; 101 | console.info('Graph', results[0]); 102 | console.info('Materials', results[1]); 103 | }.bind(this) 104 | ) 105 | .catch(function(error) { 106 | console.error(error); 107 | }); 108 | } 109 | 110 | _getGraph() { 111 | if (!this.api) { 112 | Promise.reject('getGraph: API not ready'); 113 | } 114 | 115 | return new Promise( 116 | function(resolve, reject) { 117 | this.api.getSceneGraph( 118 | function(err, result) { 119 | if (err) { 120 | return reject(err); 121 | } 122 | const doc = document.implementation.createDocument('', '', null); 123 | doc.appendChild(this._renderGraphNode(doc, result)); 124 | resolve(doc); 125 | }.bind(this) 126 | ); 127 | }.bind(this) 128 | ); 129 | } 130 | 131 | _getMaterials() { 132 | if (!this.api) { 133 | Promise.reject('getMaterials: API not ready'); 134 | } 135 | 136 | return new Promise( 137 | function(resolve, reject) { 138 | this.api.getMaterialList(function(err, materials) { 139 | if (err) { 140 | return reject(err); 141 | } 142 | resolve(materials); 143 | }); 144 | }.bind(this) 145 | ); 146 | } 147 | 148 | _getMaterialByName(materialName) { 149 | if (!this.materials) { 150 | return null; 151 | } 152 | 153 | return this.materials.reduce(function(acc, cur) { 154 | if (cur.name === materialName) { 155 | return cur; 156 | } 157 | return acc; 158 | }, null); 159 | } 160 | 161 | _renderGraphNode(doc, node) { 162 | var newNode = doc.createElement(node.type); 163 | newNode.setAttribute('instance', node.instanceID); 164 | if (node.name) { 165 | newNode.setAttribute('name', node.name); 166 | } 167 | 168 | if (node.children && node.children.length) { 169 | for (var i = 0, l = node.children.length; i < l; i++) { 170 | newNode.appendChild(this._renderGraphNode(doc, node.children[i])); 171 | } 172 | } 173 | return newNode; 174 | } 175 | 176 | _getInstanceIDsFromSelector(selector) { 177 | const nodes = Array.from(this.doc.querySelectorAll(selector)); 178 | const ids = nodes.map(function(node) { 179 | return node.getAttribute('instance'); 180 | }); 181 | return ids; 182 | } 183 | 184 | /** 185 | * Shows objects targeted by selector 186 | * @param {String} selector CSS Selector for object to show 187 | */ 188 | show(selector) { 189 | if (!this.api) { 190 | console.error('show: viewer not ready'); 191 | return; 192 | } 193 | const ids = this._getInstanceIDsFromSelector(selector); 194 | ids.forEach( 195 | function(instanceId) { 196 | this.api.show(instanceId); 197 | }.bind(this) 198 | ); 199 | } 200 | 201 | /** 202 | * Hides objects targeted by selector 203 | * @param {string} selector CSS Selector for object to hide 204 | */ 205 | hide(selector) { 206 | if (!this.api) { 207 | console.error('hide: viewer not ready'); 208 | return; 209 | } 210 | const ids = this._getInstanceIDsFromSelector(selector); 211 | ids.forEach( 212 | function(instanceId) { 213 | this.api.hide(instanceId); 214 | }.bind(this) 215 | ); 216 | } 217 | 218 | /** 219 | * Sets color (Diffuse, DiffusePBR, AlbedoPBr) for given material name 220 | * @param {string|string[]} material Name of material. Also accepts array of names for changing multiple materials at once. 221 | * @param {string} hexColor Hex color 222 | */ 223 | setColor(material, hexColor) { 224 | if (!this.api) { 225 | console.error('setColor: viewer not ready'); 226 | return; 227 | } 228 | 229 | if (!Array.isArray(material)) { 230 | material = [material]; 231 | } 232 | 233 | material.forEach(mat => { 234 | this._setMaterialColor(mat, hexColor); 235 | }); 236 | } 237 | 238 | _setMaterialColor(materialName, hexColor) { 239 | let material = this._getMaterialByName(materialName); 240 | const linearColor = srgbToLinear(hexToRgb(hexColor)); 241 | material.channels.AlbedoPBR.color = linearColor; 242 | material.channels.DiffusePBR.color = linearColor; 243 | material.channels.DiffuseColor.color = linearColor; 244 | material.channels.AlbedoPBR.texture = undefined; 245 | material.channels.DiffusePBR.texture = undefined; 246 | material.channels.DiffuseColor.texture = undefined; 247 | 248 | this.api.setMaterial(material, function(err, result) { 249 | if (err) { 250 | console.error(err); 251 | } 252 | }); 253 | } 254 | 255 | /** 256 | * Sets texture on material/channels 257 | * @param {string|string[]} material Name of material. Also accepts array of material names. 258 | * @param {string|string[]} channels Name of channel. Also accepts array of channel names. 259 | * @param {string} url URL of the texture. 260 | */ 261 | setTexture(material, channels, url) { 262 | if (!Array.isArray(material)) { 263 | material = [material]; 264 | } 265 | 266 | material.forEach(mat => { 267 | this._setMaterialTexture(mat, channels, url); 268 | }); 269 | } 270 | 271 | _setMaterialTexture(materialName, channels, url) { 272 | let material = this._getMaterialByName(materialName); 273 | const texturePromise = this._addTexture(url); 274 | texturePromise.then(textureUid => { 275 | // Accept array of channel names, or a single channel name 276 | if (!Array.isArray(channels)) { 277 | channels = [channels]; 278 | } 279 | for (var i = 0; i < channels.length; i++) { 280 | if ( 281 | material.channels.hasOwnProperty(channels[i]) && 282 | material.channels[channels[i]].texture 283 | ) { 284 | // Update texture UID 285 | material.channels[channels[i]].texture.uid = textureUid; 286 | } else { 287 | // Create new texture 288 | material.channels[channels[i]].texture = { 289 | internalFormat: 'RGB', 290 | magFilter: 'LINEAR', 291 | minFilter: 'LINEAR_MIPMAP_LINEAR', 292 | texCoordUnit: 0, 293 | textureTarget: 'TEXTURE_2D', 294 | uid: textureUid, 295 | wrapS: 'REPEAT', 296 | wrapT: 'REPEAT' 297 | }; 298 | } 299 | } 300 | this.api.setMaterial(material, function(err, result) { 301 | if (err) { 302 | console.error(err); 303 | } 304 | }); 305 | }); 306 | } 307 | 308 | _addTexture(url) { 309 | return new Promise((resolve, reject) => { 310 | if (this.textures.hasOwnProperty(url)) { 311 | resolve(this.textures[url]); 312 | } else { 313 | this.api.addTexture(url, (err, textureUid) => { 314 | if (err) { 315 | reject(err); 316 | } else { 317 | this.textures[url] = textureUid; 318 | resolve(textureUid); 319 | } 320 | }); 321 | } 322 | }); 323 | } 324 | } 325 | 326 | export default Viewer; 327 | -------------------------------------------------------------------------------- /dist/SketchfabConfigurator-1.0.2.js: -------------------------------------------------------------------------------- 1 | var SketchfabConfigurator=function(t){var n={};function e(r){if(n[r])return n[r].exports;var i=n[r]={i:r,l:!1,exports:{}};return t[r].call(i.exports,i,i.exports,e),i.l=!0,i.exports}return e.m=t,e.c=n,e.d=function(t,n,r){e.o(t,n)||Object.defineProperty(t,n,{configurable:!1,enumerable:!0,get:r})},e.r=function(t){Object.defineProperty(t,"__esModule",{value:!0})},e.n=function(t){var n=t&&t.__esModule?function(){return t.default}:function(){return t};return e.d(n,"a",n),n},e.o=function(t,n){return Object.prototype.hasOwnProperty.call(t,n)},e.p="",e(e.s=209)}([function(t,n,e){var r=e(2),i=e(35),o=e(10),u=e(18),c=e(16),a=function(t,n,e){var s,f,l,h,p=t&a.F,v=t&a.G,d=t&a.S,y=t&a.P,g=t&a.B,m=v?r:d?r[n]||(r[n]={}):(r[n]||{}).prototype,w=v?i:i[n]||(i[n]={}),b=w.prototype||(w.prototype={});for(s in v&&(e=n),e)l=((f=!p&&m&&void 0!==m[s])?m:e)[s],h=g&&f?c(l,r):y&&"function"==typeof l?c(Function.call,l):l,m&&u(m,s,l,t&a.U),w[s]!=l&&o(w,s,h),y&&b[s]!=l&&(b[s]=l)};r.core=i,a.F=1,a.G=2,a.S=4,a.P=8,a.B=16,a.W=32,a.U=64,a.R=128,t.exports=a},function(t,n){t.exports=function(t){return"object"==typeof t?null!==t:"function"==typeof t}},function(t,n){var e=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=e)},function(t,n,e){var r=e(1);t.exports=function(t){if(!r(t))throw TypeError(t+" is not an object!");return t}},function(t,n,e){var r=e(62)("wks"),i=e(23),o=e(2).Symbol,u="function"==typeof o;(t.exports=function(t){return r[t]||(r[t]=u&&o[t]||(u?o:i)("Symbol."+t))}).store=r},function(t,n){t.exports=function(t){try{return!!t()}catch(t){return!0}}},function(t,n,e){var r=e(3),i=e(91),o=e(40),u=Object.defineProperty;n.f=e(8)?Object.defineProperty:function(t,n,e){if(r(t),n=o(n,!0),r(e),i)try{return u(t,n,e)}catch(t){}if("get"in e||"set"in e)throw TypeError("Accessors not supported!");return"value"in e&&(t[n]=e.value),t}},function(t,n,e){var r=e(21),i=Math.min;t.exports=function(t){return t>0?i(r(t),9007199254740991):0}},function(t,n,e){t.exports=!e(5)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(t,n){var e={}.hasOwnProperty;t.exports=function(t,n){return e.call(t,n)}},function(t,n,e){var r=e(6),i=e(24);t.exports=e(8)?function(t,n,e){return r.f(t,n,i(1,e))}:function(t,n,e){return t[n]=e,t}},function(t,n,e){var r=e(0),i=e(35),o=e(5);t.exports=function(t,n){var e=(i.Object||{})[t]||Object[t],u={};u[t]=n(e),r(r.S+r.F*o(function(){e(1)}),"Object",u)}},function(t,n,e){var r=e(65),i=e(20);t.exports=function(t){return r(i(t))}},function(t,n,e){var r=e(36),i=e(24),o=e(12),u=e(40),c=e(9),a=e(91),s=Object.getOwnPropertyDescriptor;n.f=e(8)?s:function(t,n){if(t=o(t),n=u(n,!0),a)try{return s(t,n)}catch(t){}if(c(t,n))return i(!r.f.call(t,n),t[n])}},function(t,n,e){"use strict";if(e(8)){var r=e(34),i=e(2),o=e(5),u=e(0),c=e(66),a=e(90),s=e(16),f=e(32),l=e(24),h=e(10),p=e(33),v=e(21),d=e(7),y=e(89),g=e(30),m=e(40),w=e(9),b=e(58),_=e(1),x=e(15),S=e(57),O=e(38),E=e(27),M=e(39).f,P=e(56),j=e(23),L=e(4),A=e(37),F=e(64),T=e(59),I=e(55),k=e(28),C=e(45),N=e(46),R=e(60),V=e(83),W=e(6),D=e(13),B=W.f,G=D.f,U=i.RangeError,z=i.TypeError,H=i.Uint8Array,q=Array.prototype,Y=a.ArrayBuffer,K=a.DataView,J=A(0),X=A(2),$=A(3),Q=A(4),Z=A(5),tt=A(6),nt=F(!0),et=F(!1),rt=I.values,it=I.keys,ot=I.entries,ut=q.lastIndexOf,ct=q.reduce,at=q.reduceRight,st=q.join,ft=q.sort,lt=q.slice,ht=q.toString,pt=q.toLocaleString,vt=L("iterator"),dt=L("toStringTag"),yt=j("typed_constructor"),gt=j("def_constructor"),mt=c.CONSTR,wt=c.TYPED,bt=c.VIEW,_t=A(1,function(t,n){return Mt(T(t,t[gt]),n)}),xt=o(function(){return 1===new H(new Uint16Array([1]).buffer)[0]}),St=!!H&&!!H.prototype.set&&o(function(){new H(1).set({})}),Ot=function(t,n){var e=v(t);if(e<0||e%n)throw U("Wrong offset!");return e},Et=function(t){if(_(t)&&wt in t)return t;throw z(t+" is not a typed array!")},Mt=function(t,n){if(!(_(t)&&yt in t))throw z("It is not a typed array constructor!");return new t(n)},Pt=function(t,n){return jt(T(t,t[gt]),n)},jt=function(t,n){for(var e=0,r=n.length,i=Mt(t,r);r>e;)i[e]=n[e++];return i},Lt=function(t,n,e){B(t,n,{get:function(){return this._d[e]}})},At=function(t){var n,e,r,i,o,u,c=x(t),a=arguments.length,f=a>1?arguments[1]:void 0,l=void 0!==f,h=P(c);if(void 0!=h&&!S(h)){for(u=h.call(c),r=[],n=0;!(o=u.next()).done;n++)r.push(o.value);c=r}for(l&&a>2&&(f=s(f,arguments[2],2)),n=0,e=d(c.length),i=Mt(this,e);e>n;n++)i[n]=l?f(c[n],n):c[n];return i},Ft=function(){for(var t=0,n=arguments.length,e=Mt(this,n);n>t;)e[t]=arguments[t++];return e},Tt=!!H&&o(function(){pt.call(new H(1))}),It=function(){return pt.apply(Tt?lt.call(Et(this)):Et(this),arguments)},kt={copyWithin:function(t,n){return V.call(Et(this),t,n,arguments.length>2?arguments[2]:void 0)},every:function(t){return Q(Et(this),t,arguments.length>1?arguments[1]:void 0)},fill:function(t){return R.apply(Et(this),arguments)},filter:function(t){return Pt(this,X(Et(this),t,arguments.length>1?arguments[1]:void 0))},find:function(t){return Z(Et(this),t,arguments.length>1?arguments[1]:void 0)},findIndex:function(t){return tt(Et(this),t,arguments.length>1?arguments[1]:void 0)},forEach:function(t){J(Et(this),t,arguments.length>1?arguments[1]:void 0)},indexOf:function(t){return et(Et(this),t,arguments.length>1?arguments[1]:void 0)},includes:function(t){return nt(Et(this),t,arguments.length>1?arguments[1]:void 0)},join:function(t){return st.apply(Et(this),arguments)},lastIndexOf:function(t){return ut.apply(Et(this),arguments)},map:function(t){return _t(Et(this),t,arguments.length>1?arguments[1]:void 0)},reduce:function(t){return ct.apply(Et(this),arguments)},reduceRight:function(t){return at.apply(Et(this),arguments)},reverse:function(){for(var t,n=Et(this).length,e=Math.floor(n/2),r=0;r1?arguments[1]:void 0)},sort:function(t){return ft.call(Et(this),t)},subarray:function(t,n){var e=Et(this),r=e.length,i=g(t,r);return new(T(e,e[gt]))(e.buffer,e.byteOffset+i*e.BYTES_PER_ELEMENT,d((void 0===n?r:g(n,r))-i))}},Ct=function(t,n){return Pt(this,lt.call(Et(this),t,n))},Nt=function(t){Et(this);var n=Ot(arguments[1],1),e=this.length,r=x(t),i=d(r.length),o=0;if(i+n>e)throw U("Wrong length!");for(;o255?255:255&r),i.v[p](e*n+i.o,r,xt)}(this,e,t)},enumerable:!0})};w?(v=e(function(t,e,r,i){f(t,v,s,"_d");var o,u,c,a,l=0,p=0;if(_(e)){if(!(e instanceof Y||"ArrayBuffer"==(a=b(e))||"SharedArrayBuffer"==a))return wt in e?jt(v,e):At.call(v,e);o=e,p=Ot(r,n);var g=e.byteLength;if(void 0===i){if(g%n)throw U("Wrong length!");if((u=g-p)<0)throw U("Wrong length!")}else if((u=d(i)*n)+p>g)throw U("Wrong length!");c=u/n}else c=y(e),o=new Y(u=c*n);for(h(t,"_d",{b:o,o:p,l:u,e:c,v:new K(o)});l0?r:e)(t)}},function(t,n){t.exports=function(t){if("function"!=typeof t)throw TypeError(t+" is not a function!");return t}},function(t,n){var e=0,r=Math.random();t.exports=function(t){return"Symbol(".concat(void 0===t?"":t,")_",(++e+r).toString(36))}},function(t,n){t.exports=function(t,n){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:n}}},function(t,n,e){var r=e(1);t.exports=function(t,n){if(!r(t)||t._t!==n)throw TypeError("Incompatible receiver, "+n+" required!");return t}},function(t,n,e){var r=e(4)("unscopables"),i=Array.prototype;void 0==i[r]&&e(10)(i,r,{}),t.exports=function(t){i[r][t]=!0}},function(t,n,e){var r=e(9),i=e(15),o=e(63)("IE_PROTO"),u=Object.prototype;t.exports=Object.getPrototypeOf||function(t){return t=i(t),r(t,o)?t[o]:"function"==typeof t.constructor&&t instanceof t.constructor?t.constructor.prototype:t instanceof Object?u:null}},function(t,n){t.exports={}},function(t,n,e){var r=e(6).f,i=e(9),o=e(4)("toStringTag");t.exports=function(t,n,e){t&&!i(t=e?t:t.prototype,o)&&r(t,o,{configurable:!0,value:n})}},function(t,n,e){var r=e(21),i=Math.max,o=Math.min;t.exports=function(t,n){return(t=r(t))<0?i(t+n,0):o(t,n)}},function(t,n){var e={}.toString;t.exports=function(t){return e.call(t).slice(8,-1)}},function(t,n){t.exports=function(t,n,e,r){if(!(t instanceof n)||void 0!==r&&r in t)throw TypeError(e+": incorrect invocation!");return t}},function(t,n,e){var r=e(18);t.exports=function(t,n,e){for(var i in n)r(t,i,n[i],e);return t}},function(t,n){t.exports=!1},function(t,n){var e=t.exports={version:"2.5.4"};"number"==typeof __e&&(__e=e)},function(t,n){n.f={}.propertyIsEnumerable},function(t,n,e){var r=e(16),i=e(65),o=e(15),u=e(7),c=e(204);t.exports=function(t,n){var e=1==t,a=2==t,s=3==t,f=4==t,l=6==t,h=5==t||l,p=n||c;return function(n,c,v){for(var d,y,g=o(n),m=i(g),w=r(c,v,3),b=u(m.length),_=0,x=e?p(n,b):a?p(n,0):void 0;b>_;_++)if((h||_ in m)&&(y=w(d=m[_],_,g),t))if(e)x[_]=y;else if(y)switch(t){case 3:return!0;case 5:return d;case 6:return _;case 2:x.push(d)}else if(f)return!1;return l?-1:s||f?f:x}}},function(t,n,e){var r=e(3),i=e(205),o=e(61),u=e(63)("IE_PROTO"),c=function(){},a=function(){var t,n=e(67)("iframe"),r=o.length;for(n.style.display="none",e(87).appendChild(n),n.src="javascript:",(t=n.contentWindow.document).open(),t.write("