| @@ -349,6 +349,12 @@ | |||
| <li><a href="./simplify-modifier-plugin/">Simplify Modifier Plugin </a></li> | |||
| <li><a href="./meshopt-simplify-modifier-plugin/">MeshOpt Simplify Modifier Plugin </a></li> | |||
| </ul> | |||
| <h2 class="category">Configurators</h2> | |||
| <ul> | |||
| <li><a href="./material-configurator-plugin/">Material Configurator Plugin </a></li> | |||
| <li><a href="./switch-node-plugin/">Switch Node (Object Configurator) Plugin </a></li> | |||
| <li><a href="./gltf-khr-material-variants-plugin/">glTF KHR Material Variants Plugin </a></li> | |||
| </ul> | |||
| <h2 class="category">Import</h2> | |||
| <ul> | |||
| <li><a href="./fbx-load/">FBX Load </a></li> | |||
| @@ -415,7 +421,6 @@ | |||
| <li><a href="./object3d-generator-plugin/">Object3D Generator Plugin <br/>(Lights, Cameras)</a></li> | |||
| <li><a href="./geometry-generator-plugin/">Geometry Generator Plugin </a></li> | |||
| <li><a href="./object3d-widgets-plugin/">Object3D Widgets Plugin <br/>(Lights, Cameras)</a></li> | |||
| <li><a href="./gltf-khr-material-variants-plugin/">glTF KHR Material Variants Plugin </a></li> | |||
| <li><a href="./geometry-uv-preview/">Geometry UV Preview Plugin </a></li> | |||
| <li><a href="./parallel-asset-import/">Parallel Asset Import </a></li> | |||
| <li><a href="./obj-to-glb/">Convert OBJ to GLB </a></li> | |||
| @@ -0,0 +1,37 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Material Configurator Plugin</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <!-- Import maps polyfill --> | |||
| <!-- Remove this when import maps will be widely supported --> | |||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||
| <script type="importmap"> | |||
| { | |||
| "imports": { | |||
| "threepipe": "./../../dist/index.mjs", | |||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs", | |||
| "@threepipe/plugin-configurator": "./../../plugins/configurator/dist/index.mjs" | |||
| } | |||
| } | |||
| </script> | |||
| <style id="example-style"> | |||
| html, body, #canvas-container, #mcanvas { | |||
| width: 100%; | |||
| height: 100%; | |||
| margin: 0; | |||
| overflow: hidden; | |||
| } | |||
| </style> | |||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||
| </head> | |||
| <body> | |||
| <div id="canvas-container"> | |||
| <canvas id="mcanvas"></canvas> | |||
| </div> | |||
| </body> | |||
| @@ -0,0 +1,40 @@ | |||
| import {_testFinish, FrameFadePlugin, IObject3D, PickingPlugin, SSAAPlugin, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| import {MaterialConfiguratorPlugin} from '@threepipe/plugin-configurator' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| msaa: true, | |||
| plugins: [PickingPlugin, FrameFadePlugin, SSAAPlugin], | |||
| dropzone: { | |||
| addOptions: { | |||
| disposeSceneObjects: true, | |||
| }, | |||
| }, | |||
| }) | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| const materialConfigurator = viewer.addPluginSync(new MaterialConfiguratorPlugin()) | |||
| materialConfigurator.enableEditContextMenus = true | |||
| // await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| // This model is already setup in the editor. | |||
| // You can use the editor to setup the materials in the UI and then load the model here. | |||
| // Another way to load the material variations is to export a json file of the plugin from the editor and load it in the same way after loading the model. | |||
| await viewer.load<IObject3D>( | |||
| 'https://demo-assets.pixotronics.com/pixo/gltf/material_configurator.glb', | |||
| // 'https://demo-assets.pixotronics.com/pixo/gltf/classic-watch.glb', | |||
| { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }) | |||
| viewer.scene.mainCamera.controls!.enableDamping = true // since its disabled in the file for some reason | |||
| ui.setupPluginUi(MaterialConfiguratorPlugin) | |||
| ui.setupPluginUi(PickingPlugin) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -0,0 +1,37 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Switch Node Plugin (Configurator)</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <!-- Import maps polyfill --> | |||
| <!-- Remove this when import maps will be widely supported --> | |||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||
| <script type="importmap"> | |||
| { | |||
| "imports": { | |||
| "threepipe": "./../../dist/index.mjs", | |||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs", | |||
| "@threepipe/plugin-configurator": "./../../plugins/configurator/dist/index.mjs" | |||
| } | |||
| } | |||
| </script> | |||
| <style id="example-style"> | |||
| html, body, #canvas-container, #mcanvas { | |||
| width: 100%; | |||
| height: 100%; | |||
| margin: 0; | |||
| overflow: hidden; | |||
| } | |||
| </style> | |||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||
| </head> | |||
| <body> | |||
| <div id="canvas-container"> | |||
| <canvas id="mcanvas"></canvas> | |||
| </div> | |||
| </body> | |||
| @@ -0,0 +1,40 @@ | |||
| import {_testFinish, FrameFadePlugin, IObject3D, PickingPlugin, SSAAPlugin, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| import {SwitchNodePlugin} from '@threepipe/plugin-configurator' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| msaa: true, | |||
| plugins: [PickingPlugin, FrameFadePlugin, SSAAPlugin], | |||
| dropzone: { | |||
| addOptions: { | |||
| disposeSceneObjects: true, | |||
| }, | |||
| }, | |||
| }) | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| const configurator = viewer.addPluginSync(new SwitchNodePlugin()) | |||
| configurator.enableEditContextMenus = true | |||
| // await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| // This model is already setup in the editor. | |||
| // You can use the editor to setup the switch-nodes in the UI and then load the model here. | |||
| // Another way to load the switch node variation details is to export a json file of the plugin from the editor and load it in the same way after loading the model. | |||
| await viewer.load<IObject3D>( | |||
| 'https://demo-assets.pixotronics.com/pixo/gltf/product_configurator.glb', | |||
| // 'https://demo-assets.pixotronics.com/pixo/gltf/classic-watch.glb', | |||
| { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }) | |||
| viewer.scene.mainCamera.controls!.enableDamping = true // since its disabled in the file for some reason | |||
| ui.setupPluginUi(SwitchNodePlugin) | |||
| ui.setupPluginUi(PickingPlugin) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -22,6 +22,7 @@ | |||
| "@threepipe/plugin-extra-importers": "./../../plugins/extra-importers/dist/index.mjs", | |||
| "@threepipe/plugin-blend-importer": "./../../plugins/blend-importer/dist/index.mjs", | |||
| "@threepipe/plugin-geometry-generator": "./../../plugins/geometry-generator/dist/index.mjs", | |||
| "@threepipe/plugin-configurator": "./../../plugins/configurator/dist/index.mjs", | |||
| "@threepipe/plugin-gaussian-splatting": "./../../plugins/gaussian-splatting/dist/index.mjs" | |||
| } | |||
| } | |||
| @@ -57,6 +57,7 @@ import {BlendLoadPlugin} from '@threepipe/plugin-blend-importer' | |||
| import {extraImportPlugins} from '@threepipe/plugin-extra-importers' | |||
| import {GeometryGeneratorPlugin} from '@threepipe/plugin-geometry-generator' | |||
| import {GaussianSplattingPlugin} from '@threepipe/plugin-gaussian-splatting' | |||
| import {MaterialConfiguratorPlugin, SwitchNodePlugin} from '@threepipe/plugin-configurator' | |||
| async function init() { | |||
| @@ -121,11 +122,18 @@ async function init() { | |||
| DeviceOrientationControlsPlugin, | |||
| PointerLockControlsPlugin, | |||
| ThreeFirstPersonControlsPlugin, | |||
| // InteractionPromptPlugin, // todo disable when not in Viewer tab, like in webgi | |||
| new MeshOptSimplifyModifierPlugin(false), // will auto-initialize on first use. | |||
| // new BasicSVGRendererPlugin(false, true), | |||
| ...extraImportPlugins, | |||
| MaterialConfiguratorPlugin, | |||
| SwitchNodePlugin, | |||
| ]) | |||
| // to show more details in the UI and allow to edit changes in title etc. | |||
| viewer.getPlugin(MaterialConfiguratorPlugin)!.enableEditContextMenus = true | |||
| viewer.getPlugin(SwitchNodePlugin)!.enableEditContextMenus = true | |||
| const rt = viewer.getOrAddPluginSync(RenderTargetPreviewPlugin) | |||
| rt.addTarget({texture: viewer.getPlugin(GBufferPlugin)?.normalDepthTexture}, 'normalDepth') | |||
| rt.addTarget({texture: viewer.getPlugin(GBufferPlugin)?.flagsTexture}, 'gBufferFlags') | |||
| @@ -139,7 +147,7 @@ async function init() { | |||
| ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | |||
| ['Post-processing']: [TonemapPlugin, ProgressivePlugin, SSAOPlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | |||
| ['Export']: [CanvasSnapshotPlugin], | |||
| ['Configuration']: [GLTFKHRMaterialVariantsPlugin], | |||
| ['Configurator']: [MaterialConfiguratorPlugin, SwitchNodePlugin, GLTFKHRMaterialVariantsPlugin], | |||
| ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | |||
| ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | |||
| ['Debug']: [RenderTargetPreviewPlugin], | |||
| @@ -0,0 +1,40 @@ | |||
| { | |||
| "name": "@threepipe/plugin-configurator", | |||
| "version": "0.1.0", | |||
| "lockfileVersion": 3, | |||
| "requires": true, | |||
| "packages": { | |||
| "": { | |||
| "name": "@threepipe/plugin-configurator", | |||
| "version": "0.1.0", | |||
| "license": "Apache-2.0", | |||
| "dependencies": { | |||
| "threepipe": "file:./../../src/", | |||
| "tippy.js": "^6.3.7" | |||
| }, | |||
| "devDependencies": {} | |||
| }, | |||
| "../../src": {}, | |||
| "node_modules/@popperjs/core": { | |||
| "version": "2.11.8", | |||
| "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", | |||
| "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", | |||
| "funding": { | |||
| "type": "opencollective", | |||
| "url": "https://opencollective.com/popperjs" | |||
| } | |||
| }, | |||
| "node_modules/threepipe": { | |||
| "resolved": "../../src", | |||
| "link": true | |||
| }, | |||
| "node_modules/tippy.js": { | |||
| "version": "6.3.7", | |||
| "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", | |||
| "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", | |||
| "dependencies": { | |||
| "@popperjs/core": "^2.9.0" | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,59 @@ | |||
| { | |||
| "name": "@threepipe/plugin-configurator", | |||
| "description": "Plugins for creating material and object configurators in threepipe.", | |||
| "version": "0.1.0", | |||
| "devDependencies": { | |||
| }, | |||
| "dependencies": { | |||
| "threepipe": "file:./../../src/", | |||
| "tippy.js": "^6.3.7" | |||
| }, | |||
| "clean-package": { | |||
| "remove": [ | |||
| "clean-package", | |||
| "scripts", | |||
| "devDependencies", | |||
| "//", | |||
| "markdown-to-html" | |||
| ], | |||
| "replace": { | |||
| "dependencies": {}, | |||
| "peerDependencies": { | |||
| "threepipe": "^0.0.30" | |||
| } | |||
| } | |||
| }, | |||
| "type": "module", | |||
| "main": "dist/index.js", | |||
| "module": "dist/index.mjs", | |||
| "types": "dist/index.d.ts", | |||
| "files": [ | |||
| "dist", | |||
| "src" | |||
| ], | |||
| "scripts": { | |||
| "new:pack": "npm run prepare && clean-package && npm pack && clean-package restore", | |||
| "new:publish": "npm run prepare && clean-package && npm publish --access public && clean-package restore", | |||
| "prepare": "npm run build && npm run docs", | |||
| "build": "rimraf dist && vite build", | |||
| "dev": "NODE_ENV=development vite build --watch", | |||
| "docs": "rimraf docs && npx typedoc" | |||
| }, | |||
| "author": "repalash <palash@shaders.app>", | |||
| "license": "Apache-2.0", | |||
| "keywords": [ | |||
| "three", | |||
| "three.js", | |||
| "threepipe", | |||
| "vite", | |||
| "plugin" | |||
| ], | |||
| "bugs": { | |||
| "url": "https://github.com/repalash/threepipe/issues" | |||
| }, | |||
| "homepage": "https://github.com/repalash/threepipe#readme", | |||
| "repository": { | |||
| "type": "git", | |||
| "url": "git://github.com/repalash/threepipe.git" | |||
| } | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| .customContextGrid { | |||
| background: #eeeeee55; | |||
| border: 0.5px solid rgba(220, 220, 220, 0.2); | |||
| width: auto; | |||
| height: auto; | |||
| position: absolute; | |||
| display: flex; | |||
| flex-direction: row; | |||
| flex-wrap: wrap; | |||
| z-index: 200; | |||
| padding: 0.35rem 0.35rem; | |||
| border-radius: 0.375rem; | |||
| min-width: 5rem; | |||
| pointer-events: auto; | |||
| box-shadow: 0px 2px 6px rgba(12, 12, 12, 0.2); | |||
| color: #111111; | |||
| font-size: 0.65rem; | |||
| font-family: Inter, "Roboto Mono", "Source Code Pro", Menlo, Courier, monospace; | |||
| backdrop-filter: blur(20px); | |||
| } | |||
| .customContextGridItems { | |||
| background-color: transparent; | |||
| cursor: pointer; | |||
| border-radius: 0.25rem; | |||
| line-height: 1rem; | |||
| font-weight: 500; | |||
| overflow: hidden; | |||
| margin: 0.12rem; | |||
| } | |||
| .customContextGridItems:hover { | |||
| color: white; | |||
| /*background-color: #017AFF;*/ | |||
| box-shadow: 0 0 7px 0px rgba(64, 64, 64, 0.3); | |||
| } | |||
| .customContextGridItemImage { | |||
| width: 100%; | |||
| height: 100%; | |||
| } | |||
| .customContextGridHeading { | |||
| width: 100%; | |||
| padding: 5px; | |||
| font-size: 0.85rem; | |||
| } | |||
| .customContextGridParent { | |||
| position: absolute; | |||
| top: 0; | |||
| left: 0; | |||
| width: 270px; | |||
| height: calc(100% - 100px); | |||
| overflow-y: scroll; | |||
| z-index: 100; | |||
| display: flex; | |||
| flex-direction: column; | |||
| margin-bottom: 50px; | |||
| margin-top: 50px; | |||
| } | |||
| /* Hide scrollbar for Chrome, Safari and Opera */ | |||
| .customContextGridParent::-webkit-scrollbar { | |||
| display: none; | |||
| } | |||
| /* Hide scrollbar for IE, Edge and Firefox */ | |||
| .customContextGridParent { | |||
| -ms-overflow-style: none; /* IE and Edge */ | |||
| scrollbar-width: none; /* Firefox */ | |||
| } | |||
| @@ -0,0 +1,104 @@ | |||
| import {createDiv, createStyles, mobileAndTabletCheck} from 'ts-browser-helpers' | |||
| import tippy from 'tippy.js' | |||
| import tippyStyles from 'tippy.js/dist/tippy.css?inline' | |||
| import styles from './GridItemList.css?inline' | |||
| export interface GridItem { | |||
| id: string | |||
| image: string | |||
| onClick?: (id: string) => void | |||
| tooltip?: string | |||
| } | |||
| /** | |||
| * Earlier it was called CustomContextGrid. | |||
| * Used to create a overlay of grid items over the canvas allowing users to pick from a list of items. | |||
| */ | |||
| export class GridItemList { | |||
| public static Elements: HTMLDivElement[] = [] | |||
| // private static _inited = false | |||
| private static _container = document.createElement('div') | |||
| private static _initializeStyles(): void { | |||
| // GridItemList._inited = true // since container is cleared | |||
| createStyles(styles, GridItemList._container) | |||
| createStyles(tippyStyles, GridItemList._container) | |||
| GridItemList._container.style.position = 'absolute' | |||
| GridItemList._container.style.top = '0' | |||
| GridItemList._container.style.left = '0' | |||
| GridItemList._container.style.width = '270px' | |||
| GridItemList._container.style.height = '100%' | |||
| GridItemList._container.style.pointerEvents = 'none' | |||
| GridItemList._container.style.zIndex = '100' | |||
| GridItemList._container.style.overflowY = 'auto' | |||
| } | |||
| public static Create<T extends GridItem>(tag: string, title: string, cols: number, x: number, y: number, items: T[], processDiv: (d: HTMLDivElement, item: T, container: HTMLElement) => void): HTMLDivElement { | |||
| // if (!GridItemList._inited) GridItemList._initialize() | |||
| const isMobile = mobileAndTabletCheck() | |||
| const gap = isMobile ? 0.15 : 0.25 // rem | |||
| const itemWidth = isMobile ? 1.5 : 2.5 // rem | |||
| const margin = isMobile ? 1 : 1 // rem | |||
| // if (GridItemList.Element) GridItemList.Remove() | |||
| const container = createDiv({ | |||
| classList: ['customContextGrid'], addToBody: false, | |||
| innerHTML: ` | |||
| <div class="customContextGridHeading"> ${title} </div> | |||
| `, | |||
| }) | |||
| container.style.top = y + 'px' | |||
| container.style.left = x + 'px' | |||
| container.style.gap = gap + 'rem' | |||
| container.style.width = (itemWidth + gap) * cols - gap + margin + 'rem' // `calc(${100.0 / cols}%-${gap * cols}rem)` | |||
| container.dataset.tag = tag | |||
| for (const item of items) { | |||
| const d = createDiv({ | |||
| classList: ['customContextGridItems'], addToBody: false, innerHTML: ` | |||
| <img src="${item.image}" class="customContextGridItemImage"> | |||
| `, | |||
| }) | |||
| d.style.width = itemWidth + 'rem' | |||
| d.style.height = itemWidth + 'rem' | |||
| container.appendChild(d) | |||
| d.onclick = () => item.onClick?.(item.id) | |||
| if (item.tooltip) tippy(d, {placement: 'bottom', content: item.tooltip}) | |||
| processDiv(d, item, container) | |||
| } | |||
| GridItemList.Elements?.push(container) | |||
| return container | |||
| } | |||
| public static RemoveAll(tag?: string): void { | |||
| if (!tag) { | |||
| for (const element of GridItemList.Elements) element.remove() | |||
| GridItemList.Elements = [] | |||
| } else { | |||
| const el = GridItemList.Elements.filter(e => e.dataset.tag === tag) | |||
| for (const element of el) element.remove() | |||
| GridItemList.Elements = GridItemList.Elements.filter(e => e.dataset.tag !== tag) | |||
| } | |||
| } | |||
| public static RebuildUi(parent?: HTMLElement): void { | |||
| if (GridItemList.Elements.length === 0) return | |||
| if (!parent) parent = createDiv({addToBody: true, classList: ['customContextGridParent']}) | |||
| for (const element of GridItemList.Elements) element.remove() | |||
| GridItemList._container.innerHTML = '' | |||
| GridItemList._initializeStyles() | |||
| let y = 20 | |||
| parent.appendChild(GridItemList._container) | |||
| for (const element of GridItemList.Elements) { | |||
| element.style.top = y + 'px' | |||
| GridItemList._container.appendChild(element) | |||
| y += element.clientHeight + 20 | |||
| } | |||
| } | |||
| public static Dispose() { | |||
| GridItemList.RemoveAll() | |||
| GridItemList._container.remove() | |||
| GridItemList._container.innerHTML = '' | |||
| } | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| import {AViewerPluginSync, ThreeViewer} from 'threepipe' | |||
| import {GridItemList} from './GridItemList' | |||
| /** | |||
| * A helper plugin to create a a simple list of small grids like for material or object configurator | |||
| */ | |||
| export class GridItemListPlugin extends AViewerPluginSync<''> { | |||
| enabled = true | |||
| toJSON: any = undefined | |||
| create = GridItemList.Create | |||
| removeAll = GridItemList.RemoveAll | |||
| rebuildUi() { | |||
| if (!this._viewer) return | |||
| GridItemList.RebuildUi(this._viewer.container) // todo throttle? | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| super.onRemove(viewer) | |||
| GridItemList.Dispose() | |||
| } | |||
| } | |||
| @@ -0,0 +1,108 @@ | |||
| import {CustomContextMenu, MaterialConfiguratorBasePlugin, MaterialVariations} from 'threepipe' | |||
| import {GridItemListPlugin} from './GridItemListPlugin' | |||
| /** | |||
| * Material Configurator Plugin (Basic UI) | |||
| * This plugin allows you to create variations of materials mapped to material names or uuids in the scene. | |||
| * These variations can be applied to the materials in the scene. (This copies the properties to the same material instances instead of assigning new materials) | |||
| * The plugin interfaces with the picking plugin and also provides uiConfig to show and edit the variations. | |||
| * This functionality is inherited from `MaterialConfiguratorBasePlugin` | |||
| * | |||
| * Additionally this plugin adds a Grid UI using {@link GridItemListPlugin} in the DOM over the viewer canvas to show various material variations and allow the user to apply them. | |||
| * The UI can also be used in the editor to edit the variations and apply them. | |||
| */ | |||
| export class MaterialConfiguratorPlugin extends MaterialConfiguratorBasePlugin { | |||
| public static PluginType = 'MaterialConfiguratorPlugin' | |||
| enableEditContextMenus = false | |||
| dependencies = [GridItemListPlugin] | |||
| // must be called from preFrame | |||
| protected async _refreshUi(): Promise<boolean> { | |||
| if (!await super._refreshUi()) return false | |||
| const grid = this._viewer?.getPlugin(GridItemListPlugin) | |||
| if (!grid) return false | |||
| grid.removeAll(MaterialConfiguratorPlugin.PluginType) | |||
| for (const variation of this.variations) { | |||
| const container = grid.create(MaterialConfiguratorPlugin.PluginType, | |||
| variation.title + (this.enableEditContextMenus ? ' (' + variation.uuid + ')' : ''), | |||
| 5, | |||
| 20, 0, | |||
| variation.materials.map(m => { | |||
| const image = this.getPreview(m, variation.preview) | |||
| return { | |||
| id: m.uuid, | |||
| image, // : (m as any).map?.image ? imageBitmapToBase64((m as any).map.image, 100) : makeColorSvg((m as any).color ?? '#ffffff'), | |||
| onClick: (id:string) => this.applyVariation(variation, id), | |||
| tooltip: m.name || m.uuid, | |||
| } | |||
| }), (d, item)=> { | |||
| // todo test in shadow dom. | |||
| d.oncontextmenu = (e) => { | |||
| if (!this.enableEditContextMenus) return | |||
| e.preventDefault() | |||
| e.stopPropagation() | |||
| const menu = CustomContextMenu.Create(this.materialContextMenuItems(variation, item.id), e.clientX, e.clientY) | |||
| document.body.appendChild(menu) | |||
| } | |||
| }) | |||
| container.oncontextmenu = (e) => { | |||
| if (!this.enableEditContextMenus) return | |||
| e.preventDefault() | |||
| e.stopPropagation() | |||
| const menu = CustomContextMenu.Create(this.variationsContextMenuItems(variation), e.clientX, e.clientY) | |||
| document.body.appendChild(menu) | |||
| } | |||
| } | |||
| grid.rebuildUi() | |||
| return true | |||
| } | |||
| materialContextMenuItems = (variation: MaterialVariations, uuid: string)=>({ | |||
| ['Remove']: async()=>{ | |||
| const conf = await this._viewer?.dialog.confirm('Remove material: Remove material from this variation list?') | |||
| if (!conf) return | |||
| variation.materials = variation.materials.filter(m => m.uuid !== uuid) | |||
| this.refreshUi() | |||
| CustomContextMenu.Remove() | |||
| }, | |||
| // todo set icon url | |||
| }) | |||
| variationsContextMenuItems = (variation: MaterialVariations)=>({ | |||
| ['Rename mapping']: async() => { | |||
| const name = await this._viewer?.dialog.prompt('Change name: New material name to map to', variation.uuid, true) | |||
| if (name) { | |||
| variation.uuid = name | |||
| this.refreshUi() | |||
| } | |||
| }, | |||
| ['Rename title']: async() => { | |||
| const name = await this._viewer?.dialog.prompt('Change name: New material name to map to', variation.title, true) | |||
| if (name) { | |||
| variation.title = name | |||
| this.refreshUi() | |||
| } | |||
| }, | |||
| ['Clear Materials']: async()=>{ | |||
| const conf = await this._viewer?.dialog.confirm('Remove all: Remove all materials from this variation list?') | |||
| if (!conf) return | |||
| variation.materials = [] | |||
| this.refreshUi() | |||
| CustomContextMenu.Remove() | |||
| }, | |||
| ['Remove Section']: async()=>{ | |||
| const conf = await this._viewer?.dialog.confirm('Remove variations: Remove this category of variations?') | |||
| if (!conf) return | |||
| this.removeVariation(variation) | |||
| CustomContextMenu.Remove() | |||
| }, | |||
| }) | |||
| } | |||
| @@ -0,0 +1,79 @@ | |||
| import {SwitchNodeBasePlugin} from 'threepipe' | |||
| import {GridItemListPlugin} from './GridItemListPlugin' | |||
| /** | |||
| * Switch Node Plugin (Basic UI) | |||
| * This plugin allows you to configure object variations in a file and apply them in the scene. | |||
| * Each SwitchNode is a parent object with multiple direct children. Only one child is visible at a time. | |||
| * This works by toggling the `visible` property of the children of a parent object. | |||
| * The plugin interfaces with the picking plugin and also provides uiConfig to show and edit the variations. | |||
| * It also provides a function to create snapshot previews of individual variations. This creates a limited render of the object with the selected child visible. | |||
| * To get a proper render, its better to render it offline and set the image as a preview. | |||
| * This functionality is inherited from `SwitchNodeBasePlugin`. | |||
| * | |||
| * Additionally this plugin adds a Grid UI using {@link GridItemListPlugin} in the DOM over the viewer canvas to show various object variations and allow the user to select them. | |||
| * The UI can also be used in the editor to edit the variations and apply them. | |||
| */ | |||
| export class SwitchNodePlugin extends SwitchNodeBasePlugin { | |||
| public static readonly PluginType = 'SwitchNodePlugin' | |||
| enableEditContextMenus = false | |||
| dependencies = [GridItemListPlugin] | |||
| protected _refreshUi(): boolean { | |||
| if (!super._refreshUi()) return false | |||
| const grid = this._viewer?.getPlugin(GridItemListPlugin) | |||
| if (!grid) return false | |||
| grid.removeAll(SwitchNodePlugin.PluginType) | |||
| for (const variation of this.variations) { | |||
| const obj = this._viewer!.scene.getObjectByName(variation.name) | |||
| if (!obj) { | |||
| console.warn('no object found for variation, skipping', variation) | |||
| continue | |||
| } | |||
| if (obj.children.length < 1) { | |||
| console.warn('SwitchNode does not have enough children', variation) | |||
| } | |||
| const container = grid.create(SwitchNodePlugin.PluginType, | |||
| variation.title + (this.enableEditContextMenus ? ' (' + variation.name + ')' : ''), | |||
| Math.min(5, obj.children.length), | |||
| 20, 0, | |||
| obj.children.map(child => { | |||
| return { | |||
| id: child.uuid, | |||
| image: this.getPreview(variation, child), | |||
| onClick: () => { | |||
| this.selectNode(variation, child.name || child.uuid) | |||
| }, | |||
| tooltip: child.name || child.uuid, | |||
| } | |||
| }), (d, _item)=> { | |||
| // todo test in shadow dom. | |||
| d.oncontextmenu = (e) => { | |||
| if (!this.enableEditContextMenus) return | |||
| e.preventDefault() | |||
| e.stopPropagation() | |||
| // todo | |||
| // const menu = CustomContextMenu.Create(this.materialContextMenuItems(variation, item.id), e.clientX, e.clientY) | |||
| // document.body.appendChild(menu) | |||
| } | |||
| } | |||
| ) | |||
| container.oncontextmenu = (e) => { | |||
| if (!this.enableEditContextMenus) return | |||
| e.preventDefault() | |||
| e.stopPropagation() | |||
| // todo | |||
| // const menu = CustomContextMenu.Create(this.variationsContextMenuItems(variation), e.clientX, e.clientY) | |||
| // document.body.appendChild(menu) | |||
| } | |||
| } | |||
| grid.rebuildUi() | |||
| return true | |||
| } | |||
| } | |||
| @@ -0,0 +1,40 @@ | |||
| declare module '*.txt' { | |||
| const content: string | |||
| export default content | |||
| } | |||
| declare module '*.glsl' { | |||
| const content: string | |||
| export default content | |||
| } | |||
| declare module '*.vert' { | |||
| const content: string | |||
| export default content | |||
| } | |||
| declare module '*.frag' { | |||
| const content: string | |||
| export default content | |||
| } | |||
| declare module '*.module.scss' { | |||
| const content: any | |||
| export default content | |||
| export const stylesheet: string | |||
| } | |||
| declare module '*.module.css' { | |||
| const content: any | |||
| export default content | |||
| export const stylesheet: string | |||
| } | |||
| declare module '*.css' { | |||
| const content: string | |||
| export default content | |||
| } | |||
| declare module '*.css?inline' { // for vite | |||
| const content: string | |||
| export default content | |||
| } | |||
| // export {} | |||
| // hack for typedoc | |||
| // eslint-disable-next-line @typescript-eslint/naming-convention | |||
| // declare type OffscreenCanvas = HTMLCanvasElement | |||
| @@ -0,0 +1,4 @@ | |||
| export {MaterialConfiguratorPlugin} from './MaterialConfiguratorPlugin' | |||
| export {GridItemListPlugin} from './GridItemListPlugin' | |||
| export {GridItemList} from './GridItemList' | |||
| export {SwitchNodePlugin} from './SwitchNodePlugin' | |||
| @@ -0,0 +1,41 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "baseUrl": "./src", | |||
| "rootDir": "./src", | |||
| "allowJs": true, | |||
| "checkJs": false, | |||
| "skipLibCheck": true, | |||
| "allowSyntheticDefaultImports": true, | |||
| "experimentalDecorators": true, | |||
| "isolatedModules": true, | |||
| "module": "es2020", | |||
| "noImplicitAny": true, | |||
| "declaration": true, | |||
| "declarationMap": true, | |||
| "declarationDir": "dist", | |||
| "outDir": "dist", | |||
| "noImplicitThis": true, | |||
| "noUnusedLocals": true, | |||
| "noUnusedParameters": true, | |||
| "removeComments": false, | |||
| "preserveConstEnums": true, | |||
| "moduleResolution": "node", | |||
| "emitDecoratorMetadata": false, | |||
| "sourceMap": true, | |||
| "target": "ES2021", | |||
| "strictNullChecks": true, | |||
| "lib": [ | |||
| "es2020", | |||
| "esnext", | |||
| "dom" | |||
| ] | |||
| }, | |||
| "include": [ | |||
| "src/**/*" | |||
| ], | |||
| "exclude": [ | |||
| "node_modules", | |||
| "**/*.spec.ts", | |||
| "dist" | |||
| ] | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| { | |||
| "extends": [ | |||
| "../../typedoc.json" | |||
| ], | |||
| "entryPoints": [ | |||
| "src/index.ts" | |||
| ], | |||
| "name": "Threepipe Configurator Plugins", | |||
| "readme": "none" | |||
| } | |||
| @@ -0,0 +1,91 @@ | |||
| import {defineConfig} from 'vite' | |||
| import json from '@rollup/plugin-json'; | |||
| import dts from 'vite-plugin-dts' | |||
| import packageJson from './package.json'; | |||
| import license from 'rollup-plugin-license'; | |||
| import replace from '@rollup/plugin-replace'; | |||
| import glsl from 'rollup-plugin-glsl'; | |||
| import path from 'node:path'; | |||
| const isProd = process.env.NODE_ENV === 'production' | |||
| const { name, version, author } = packageJson | |||
| const {main, module, browser} = packageJson | |||
| const globals = { | |||
| 'three': 'threepipe', // just incase someone uses three | |||
| 'threepipe': 'threepipe', | |||
| '@threepipe/plugin-tweakpane': '@threepipe/plugin-tweakpane', | |||
| } | |||
| export default defineConfig({ | |||
| optimizeDeps: { | |||
| exclude: ['uiconfig.js', 'ts-browser-helpers'], | |||
| }, | |||
| base: '', | |||
| // define: { | |||
| // 'process.env': process.env | |||
| // }, | |||
| build: { | |||
| sourcemap: true, | |||
| minify: false, | |||
| cssMinify: isProd, | |||
| cssCodeSplit: false, | |||
| watch: !isProd ? { | |||
| buildDelay: 1000, | |||
| } : null, | |||
| lib: { | |||
| entry: 'src/index.ts', | |||
| formats: isProd ? ['es', 'umd'] : ['es'], | |||
| name: name, | |||
| fileName: (format) => (format === 'umd' ? main : module).replace('dist/', ''), | |||
| }, | |||
| outDir: 'dist', | |||
| emptyOutDir: isProd, | |||
| commonjsOptions: { | |||
| exclude: [/uiconfig.js/, /ts-browser-helpers/], | |||
| }, | |||
| rollupOptions: { | |||
| output: { | |||
| // inlineDynamicImports: false, | |||
| globals, | |||
| }, | |||
| external: Object.keys(globals), | |||
| }, | |||
| }, | |||
| plugins: [ | |||
| isProd ? dts({tsconfigPath: './tsconfig.json'}) : null, | |||
| replace({ | |||
| 'from \'three\'': 'from \'threepipe\'', | |||
| delimiters: ['', ''], | |||
| }), | |||
| replace({ | |||
| 'process.env.NODE_ENV': JSON.stringify(isProd ? 'production' : 'development'), | |||
| preventAssignment: true, | |||
| }), | |||
| glsl({ // todo: minify glsl. | |||
| include: 'src/**/*.glsl', | |||
| }), | |||
| json(), | |||
| // postcss({ | |||
| // modules: false, | |||
| // autoModules: true, // todo; issues with typescript import css, because inject is false | |||
| // inject: false, | |||
| // minimize: isProduction, | |||
| // // Or with custom options for `postcss-modules` | |||
| // }), | |||
| license({ | |||
| banner: ` | |||
| @license | |||
| ${name} v${version} | |||
| Copyright 2022<%= moment().format('YYYY') > 2022 ? '-' + moment().format('YYYY') : null %> ${author} | |||
| ${packageJson.license} License | |||
| See ./dependencies.txt for any bundled third-party dependencies and licenses. | |||
| `, | |||
| thirdParty: { | |||
| output: path.join(__dirname, 'dist', 'dependencies.txt'), | |||
| includePrivate: true, // Default is false. | |||
| }, | |||
| }), | |||
| ], | |||
| }) | |||
| @@ -0,0 +1,298 @@ | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {PickingPlugin} from '../interaction/PickingPlugin' | |||
| import {imageBitmapToBase64, makeColorSvgCircle, serialize} from 'ts-browser-helpers' | |||
| import {UiObjectConfig} from 'uiconfig.js' | |||
| import {IMaterial, PhysicalMaterial} from '../../core' | |||
| import {MaterialPreviewGenerator} from '../../three' | |||
| import {Color} from 'three' | |||
| /** | |||
| * Material Configurator Plugin (Base) | |||
| * This plugin allows you to create variations of materials mapped to material names or uuids in the scene. | |||
| * These variations can be applied to the materials in the scene. (This copies the properties to the same material instances instead of assigning new materials) | |||
| * The plugin interfaces with the picking plugin and also provides uiConfig to show and edit the variations. | |||
| * | |||
| * See `MaterialConfiguratorPlugin` in [plugin-configurator](https://threepipe.org/plugins/configurator/docs/index.html) for example on inheriting with a custom UI renderer. | |||
| * | |||
| * @category Plugins | |||
| */ | |||
| export class MaterialConfiguratorBasePlugin extends AViewerPluginSync<''> { | |||
| enabled = true | |||
| public static PluginType = 'MaterialConfiguratorPlugin' | |||
| private _picking: PickingPlugin | undefined | |||
| protected _previewGenerator: MaterialPreviewGenerator | undefined | |||
| private _uiNeedRefresh = false | |||
| constructor() { | |||
| super() | |||
| this.addEventListener('deserialize', this.refreshUi) | |||
| this.refreshUi = this.refreshUi.bind(this) | |||
| this._refreshUi = this._refreshUi.bind(this) | |||
| this._refreshUiConfig = this._refreshUiConfig.bind(this) | |||
| } | |||
| onAdded(viewer: ThreeViewer) { | |||
| super.onAdded(viewer) | |||
| // todo subscribe to plugin add event if picking is not added yet. | |||
| this._picking = viewer.getPlugin<PickingPlugin>('Picking') | |||
| this._previewGenerator = new MaterialPreviewGenerator() | |||
| this._picking?.addEventListener('selectedObjectChanged', this._refreshUiConfig) | |||
| viewer.addEventListener('preFrame', this._refreshUi) | |||
| } | |||
| /** | |||
| * Apply all variations(by selected index or first item) when a config is loaded | |||
| */ | |||
| applyOnLoad = true | |||
| /** | |||
| * Reapply all selected variations again. | |||
| * Useful when the scene is loaded or changed and the variations are not applied. | |||
| */ | |||
| reapplyAll() { | |||
| this.variations.forEach(v => this.applyVariation(v, v.materials[v.selectedIndex ?? 0].uuid)) | |||
| } | |||
| fromJSON(data: any, meta?: any): this | Promise<this | null> | null { | |||
| this.variations = [] | |||
| if (!super.fromJSON(data, meta)) return null // its not a promise | |||
| if (data.applyOnLoad === undefined) { // old files | |||
| this.applyOnLoad = false | |||
| } | |||
| if (this.applyOnLoad) this.reapplyAll() | |||
| return this | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| this._previewGenerator?.dispose() | |||
| this._previewGenerator = undefined | |||
| this._picking?.removeEventListener('selectedObjectChanged', this._refreshUiConfig) | |||
| this.removeEventListener('deserialize', this.refreshUi) | |||
| viewer.removeEventListener('preFrame', this._refreshUi) | |||
| this._picking = undefined | |||
| return super.onRemove(viewer) | |||
| } | |||
| findVariation(uuid?: string): MaterialVariations|undefined { | |||
| return uuid ? this.variations.find(v => v.uuid === uuid) : undefined | |||
| } | |||
| getSelectedVariation(): MaterialVariations|undefined { | |||
| return this.findVariation(this._selectedMaterial()?.uuid) || this.findVariation(this._selectedMaterial()?.name) | |||
| } | |||
| /** | |||
| * Apply a material variation based on index or uuid. | |||
| * @param variations | |||
| * @param matUuidOrIndex | |||
| */ | |||
| applyVariation(variations: MaterialVariations, matUuidOrIndex: string|number): boolean { | |||
| const m = this._viewer?.materialManager | |||
| if (!m) return false | |||
| const material = typeof matUuidOrIndex === 'string' ? | |||
| variations.materials.find(m1 => m1.uuid === matUuidOrIndex) : | |||
| variations.materials[matUuidOrIndex] | |||
| if (!material) return false | |||
| variations.selectedIndex = variations.materials.indexOf(material) | |||
| return m.applyMaterial(material, variations.uuid) | |||
| } | |||
| /** | |||
| * Get the preview for a material variation | |||
| * Should be called from preFrame ideally. (or preRender but set viewerSetDirty = false) | |||
| * @param preview - Type of preview. Could be generate:sphere, generate:cube, color, map, emissive, etc. | |||
| * @param material - Material or index of the material in the variation. | |||
| * @param viewerSetDirty - call viewer.setDirty() after setting the preview. So that the preview is cleared from the canvas. | |||
| */ | |||
| getPreview(material: IMaterial, preview: string, viewerSetDirty = true): string { | |||
| if (!this._viewer) return '' | |||
| // const m = typeof material === 'number' ? variation.materials[material] : material | |||
| const m = material | |||
| if (!m) return '' | |||
| let image = '' | |||
| if (!preview.startsWith('generate:')) { | |||
| const pp = (m as any)[preview] || '#ff00ff' | |||
| image = pp.image ? imageBitmapToBase64(pp.image, 100) : '' | |||
| if (!image.length) image = makeColorSvgCircle(pp.isColor ? (pp as Color).getHexString() : pp) | |||
| } else { | |||
| image = this._previewGenerator!.generate(m, | |||
| this._viewer.renderManager.renderer, | |||
| this._viewer.scene.environment, | |||
| preview.split(':')[1] | |||
| ) | |||
| } | |||
| if (viewerSetDirty) this._viewer.setDirty() // because called from preFrame | |||
| return image | |||
| } | |||
| /** | |||
| * Refreshes the UI in the next frame | |||
| */ | |||
| refreshUi(): void { | |||
| if (!this.enabled || !this._viewer) return | |||
| this._uiNeedRefresh = true | |||
| } | |||
| private _refreshUiConfig() { | |||
| if (!this.enabled) return | |||
| this.uiConfig.uiRefresh?.() // don't call this.refreshUi here | |||
| } | |||
| // must be called from preFrame | |||
| protected async _refreshUi(): Promise<boolean> { | |||
| if (!this.enabled) return false | |||
| if (!this._viewer || !this._uiNeedRefresh) return false | |||
| this._uiNeedRefresh = false | |||
| this._refreshUiConfig() | |||
| return true | |||
| } | |||
| @serialize() | |||
| variations: MaterialVariations[] = [] | |||
| private _selectedMaterial = () => (this._picking?.getSelectedObject()?.material || undefined) as IMaterial | undefined | |||
| uiConfig: UiObjectConfig = { | |||
| label: 'Material Configurator', | |||
| type: 'folder', | |||
| // expanded: true, | |||
| children: [ | |||
| () => [ | |||
| { | |||
| type: 'input', | |||
| label: 'uuid', | |||
| property: [this._selectedMaterial(), 'uuid'], | |||
| hidden: () => !this._selectedMaterial(), | |||
| disabled: true, | |||
| }, | |||
| { | |||
| type: 'input', | |||
| label: 'mapping', | |||
| hidden: () => !this._selectedMaterial(), | |||
| property: () => [this.getSelectedVariation(), 'uuid'], | |||
| onChange: async() => this.refreshUi(), | |||
| }, | |||
| { | |||
| type: 'input', | |||
| label: 'title', | |||
| hidden: () => !this._selectedMaterial(), | |||
| property: () => [this.getSelectedVariation(), 'title'], | |||
| onChange: async() => this.refreshUi(), | |||
| }, | |||
| { | |||
| type: 'dropdown', | |||
| label: 'Preview Type', | |||
| hidden: () => !this._selectedMaterial(), | |||
| property: () => [this.getSelectedVariation(), 'preview'], | |||
| onChange: async() => this.refreshUi(), | |||
| children: ['generate:sphere', 'generate:cube', 'color', 'map', 'emissive', ...Object.keys(PhysicalMaterial.MaterialProperties).filter(x => x.endsWith('Map'))].map(k => ({ | |||
| label: k, | |||
| value: k, | |||
| })), | |||
| }, | |||
| ...this.getSelectedVariation()?.materials.map(m => { | |||
| return m.uiConfig ? Object.assign(m.uiConfig, {expanded: false}) : {} | |||
| }) || [], | |||
| { | |||
| type: 'button', | |||
| label: 'Clear variations', | |||
| hidden: () => !this._selectedMaterial(), | |||
| value: async() => { | |||
| const v = this.getSelectedVariation() | |||
| if (v && await this._viewer!.dialog.confirm('Material configurator: Remove all variations for this material?')) v.materials = [] | |||
| this.refreshUi() | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Remove completely', | |||
| hidden: () => !this._selectedMaterial(), | |||
| value: async() => { | |||
| const v = this.getSelectedVariation() | |||
| if (v && await this._viewer!.dialog.confirm('Material configurator: Remove this variation?')) { | |||
| this.removeVariation(v) | |||
| } | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Add Variation', | |||
| hidden: () => !this._selectedMaterial(), | |||
| value: async() => { | |||
| const mat = this._selectedMaterial() | |||
| if (!mat) return | |||
| if (!mat.name && !await this._viewer?.dialog.confirm('Material configurator: Material has no name. Use uuid instead?')) return | |||
| this.addVariation(mat) | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Refresh Ui', | |||
| value: () => this.refreshUi(), | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Apply All', | |||
| value: () => { | |||
| this.variations.forEach(v => this.applyVariation(v, v.materials[0].uuid)) | |||
| }, | |||
| }, | |||
| ], | |||
| ], | |||
| } | |||
| removeVariationForMaterial(material: IMaterial) { | |||
| let variation = this.findVariation(material.uuid) | |||
| if (!variation && material.name.length > 0) variation = this.findVariation(material.name) | |||
| if (variation) this.removeVariation(variation) | |||
| } | |||
| removeVariation(variation: MaterialVariations) { | |||
| if (!variation) return | |||
| this.variations.splice(this.variations.indexOf(variation), 1) | |||
| this.refreshUi() | |||
| } | |||
| addVariation(material?: IMaterial) { | |||
| const clone = material?.clone?.() | |||
| if (material && clone) { | |||
| let variation = this.findVariation(material.uuid) | |||
| if (!variation && material.name.length > 0) variation = this.findVariation(material.name) | |||
| if (!variation) { | |||
| variation = this.createVariation(material) | |||
| } | |||
| variation.materials.push(clone) | |||
| this.refreshUi() | |||
| } | |||
| } | |||
| createVariation(material: IMaterial) { | |||
| this.variations.push({ | |||
| uuid: material.name.length > 0 ? material.name : material.uuid, | |||
| title: material.name.length > 0 ? material.name : 'No Name', | |||
| preview: 'generate:sphere', | |||
| materials: [], | |||
| }) | |||
| return this.variations[this.variations.length - 1] | |||
| } | |||
| } | |||
| export interface MaterialVariations { | |||
| /** | |||
| * The name or the uuid of the material in the scene | |||
| */ | |||
| uuid: string | |||
| /** | |||
| * Title to show in the UI | |||
| */ | |||
| title: string | |||
| preview: keyof PhysicalMaterial | 'generate:sphere' | 'generate:cube' | 'generate:cylinder' | |||
| materials: IMaterial[] | |||
| data?: { | |||
| icon?: string, | |||
| [key: string]: any | |||
| }[] | |||
| selectedIndex?: number | |||
| } | |||
| @@ -0,0 +1,265 @@ | |||
| import {Object3D, Vector3} from 'three' | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {PickingPlugin} from '../interaction/PickingPlugin' | |||
| import {UiObjectConfig} from 'uiconfig.js' | |||
| import {serialize} from 'ts-browser-helpers' | |||
| import {snapObject} from '../../three' | |||
| /** | |||
| * Switch Node Plugin (Base) | |||
| * This plugin allows you to configure object variations in a file and apply them in the scene. | |||
| * Each SwitchNode is a parent object with multiple direct children. Only one child is visible at a time. | |||
| * This works by toggling the `visible` property of the children of a parent object. | |||
| * The plugin interfaces with the picking plugin and also provides uiConfig to show and edit the variations. | |||
| * It also provides a function to create snapshot previews of individual variations. This creates a limited render of the object with the selected child visible. | |||
| * To get a proper render, its better to render it offline and set the image as a preview. | |||
| * | |||
| * See `SwitchNodePlugin` in [plugin-configurator](https://threepipe.org/plugins/configurator/docs/index.html) for example on inheriting with a custom UI renderer. | |||
| * | |||
| * @category Plugins | |||
| */ | |||
| export class SwitchNodeBasePlugin extends AViewerPluginSync<''> { | |||
| public static readonly PluginType = 'SwitchNodePlugin' | |||
| enabled = true | |||
| private _picking: PickingPlugin | undefined | |||
| private _uiNeedRefresh = false | |||
| constructor() { | |||
| super() | |||
| this._postFrame = this._postFrame.bind(this) | |||
| this.refreshUiConfig = this.refreshUiConfig.bind(this) | |||
| this.addEventListener('deserialize', async() => { | |||
| // await timeout(200) // not needed actually | |||
| this.refreshUi() | |||
| }) | |||
| } | |||
| onAdded(viewer: ThreeViewer) { | |||
| super.onAdded(viewer) | |||
| // todo subscribe to plugin add event if picking is not added yet. | |||
| this._picking = viewer.getPlugin<PickingPlugin>('Picking') | |||
| this._picking?.addEventListener('selectedObjectChanged', this.refreshUiConfig) // don't call this.refreshUi here | |||
| viewer.addEventListener('postFrame', this._postFrame) | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| this._picking = viewer.getPlugin<PickingPlugin>('Picking') | |||
| this._picking?.removeEventListener('selectedObjectChanged', this.refreshUiConfig) | |||
| viewer.removeEventListener('postFrame', this._postFrame) | |||
| super.onRemove(viewer) | |||
| } | |||
| protected _postFrame() { | |||
| if (this._uiNeedRefresh) this._refreshUi() // only call this from here. | |||
| } | |||
| /** | |||
| * Select a switch node variation with name or uuid. | |||
| * @param node | |||
| * @param nameOrUuid | |||
| * @param setDirty - set dirty in the viewer after update. | |||
| */ | |||
| selectNode(node: ObjectSwitchNode, nameOrUuid: string|number, setDirty = true) { | |||
| const obj = this._viewer?.scene.getObjectByName(node.name) | |||
| if (!obj || obj.children.length < 1) return | |||
| const child = typeof nameOrUuid === 'number' ? | |||
| obj.children[nameOrUuid] : | |||
| obj.children.find(c => c.name === nameOrUuid || c.uuid === nameOrUuid) | |||
| if (!child) { | |||
| this._viewer?.console.warn('SwitchNodePlugin: child not found', nameOrUuid) | |||
| return false | |||
| } | |||
| node.selected = child.name || child.uuid | |||
| let changed = false | |||
| for (const child1 of obj.children) { | |||
| const visible = child1.visible | |||
| child1.visible = (child1.name || child1.uuid) === node.selected | |||
| changed = changed || visible !== child1.visible | |||
| } | |||
| if (changed && setDirty) this._viewer!.scene.setDirty({refreshScene: true, frameFade: true}) | |||
| return changed | |||
| } | |||
| /** | |||
| * Apply all variations(by selected index or first item) when a config is loaded | |||
| */ | |||
| applyOnLoad = true | |||
| /** | |||
| * Reapply all selected variations again. | |||
| * Useful when the scene is loaded or changed and the variations are not applied. | |||
| */ | |||
| reapplyAll() { | |||
| this.variations.forEach(v => this.selectNode(v, v.selected || 0, false)) | |||
| this._viewer!.scene.setDirty({refreshScene: true, frameFade: true}) | |||
| } | |||
| fromJSON(data: any, meta?: any): this | Promise<this | null> | null { | |||
| this.variations = [] | |||
| if (!super.fromJSON(data, meta)) return null // its not a promise | |||
| if (data.applyOnLoad === undefined) { // old files | |||
| this.applyOnLoad = true // setting true because all the items will be visible otherwise. | |||
| } | |||
| if (this.applyOnLoad) this.reapplyAll() | |||
| return this | |||
| } | |||
| refreshUi() { | |||
| if (!this.enabled) return | |||
| this._uiNeedRefresh = true | |||
| } | |||
| protected _refreshUi(): boolean { | |||
| if (!this.enabled) return false | |||
| if (!this._viewer) return false | |||
| this._uiNeedRefresh = false | |||
| this.refreshUiConfig() | |||
| return true | |||
| } | |||
| refreshUiConfig() { | |||
| if (!this.enabled) return | |||
| this.uiConfig.uiRefresh?.() | |||
| } | |||
| @serialize() variations: ObjectSwitchNode[] = [] | |||
| protected _selectedSwitchNode = (): Object3D | undefined => { | |||
| const obj = this._picking?.getSelectedObject() // (?.material || undefined) as IMaterial | undefined | |||
| if (!obj) return undefined | |||
| const nodes = this.variations.map(v => v.name) | |||
| let found: Object3D | undefined = undefined | |||
| obj.traverseAncestors(a => { | |||
| if (found) return | |||
| if (!a.name) return | |||
| if (nodes.includes(a.name)) found = a | |||
| }) | |||
| return found | |||
| } | |||
| /** | |||
| * Get the preview for a switch node variation | |||
| * Should be called from preFrame ideally. (or preRender but set viewerSetDirty = false) | |||
| * @param child - Child Object to get the preview for | |||
| * @param variation - Switch node variation that contains the child. | |||
| * @param viewerSetDirty - call viewer.setDirty() after setting the preview. So that the preview is cleared from the canvas. | |||
| */ | |||
| getPreview(variation: ObjectSwitchNode, child: Object3D, viewerSetDirty = true): string { | |||
| if (!this._viewer || !variation) return '' | |||
| // const m = typeof material === 'number' ? variation.materials[material] : material | |||
| const cv = variation.camView | |||
| const camOffset = new Vector3( | |||
| (cv.includes('right') ? 1 : 0) - (cv.includes('left') ? 1 : 0), | |||
| (cv.includes('top') ? 1 : 0) - (cv.includes('bottom') ? 1 : 0), | |||
| (cv.includes('front') ? 1 : 0) - (cv.includes('back') ? 1 : 0) | |||
| ) | |||
| if (!variation.camDistance) variation.camDistance = 1 | |||
| const image = snapObject(this._viewer!.renderManager.renderer, child, this._viewer?.scene, 7, camOffset.multiplyScalar(variation.camDistance * 2)) | |||
| if (viewerSetDirty) this._viewer.setDirty() // because called from preFrame | |||
| return image | |||
| } | |||
| addNode(node: ObjectSwitchNode, refreshUi = true) { | |||
| this.variations.push(node) | |||
| if (refreshUi) this.refreshUi() | |||
| } | |||
| uiConfig: UiObjectConfig = { | |||
| label: 'Switch Node Plugin', | |||
| type: 'folder', | |||
| // expanded: true, | |||
| children: [ | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Enabled', | |||
| property: [this, 'enabled'], | |||
| }, | |||
| () => [ | |||
| { | |||
| type: 'folder', | |||
| label: 'All nodes', | |||
| expanded: true, | |||
| children: [ | |||
| this.variations.map(v => ({ | |||
| type: 'input', | |||
| label: v.title, | |||
| property: [v, 'name'], | |||
| onChange: () => this.refreshUi(), | |||
| })), | |||
| ], | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Add Node', | |||
| value: () => { | |||
| this.addNode({ | |||
| name: 'switch_node', | |||
| selected: '', | |||
| title: 'Switch Node', | |||
| camView: 'front', | |||
| camDistance: 1, | |||
| }) | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Refresh UI', | |||
| value: () => this.refreshUi(), | |||
| }, | |||
| { | |||
| type: 'input', | |||
| label: 'Selected node title', | |||
| hidden: () => !this._selectedSwitchNode(), | |||
| property: () => { | |||
| const node = this._selectedSwitchNode() | |||
| if (!node) return [] | |||
| return [this.variations.find(v => v.name === node.name), 'title'] | |||
| }, | |||
| onChange: () => this.refreshUi(), | |||
| }, | |||
| { | |||
| type: 'slider', | |||
| bounds: [0.01, 2], | |||
| stepSize: 0.01, | |||
| label: 'Cam Distance', | |||
| hidden: () => !this._selectedSwitchNode(), | |||
| property: () => { | |||
| const node = this._selectedSwitchNode() | |||
| if (!node) return [] | |||
| return [this.variations.find(v => v.name === node.name), 'camDistance'] | |||
| }, | |||
| // onChange: ()=> this.refreshUi(), | |||
| }, | |||
| { | |||
| type: 'dropdown', | |||
| label: 'Cam View', | |||
| hidden: () => !this._selectedSwitchNode(), | |||
| property: () => { | |||
| const node = this._selectedSwitchNode() | |||
| if (!node) return [] | |||
| return [this.variations.find(v => v.name === node.name), 'camView'] | |||
| }, | |||
| onChange: () => this.refreshUi(), | |||
| children: ['top', 'bottom', 'front', 'back', 'left', 'right'].map(k => ({ | |||
| label: k, | |||
| value: k, | |||
| })), | |||
| }, | |||
| ], | |||
| ], | |||
| } | |||
| } | |||
| export interface ObjectSwitchNode{ | |||
| name: string, | |||
| title: string, | |||
| selected: string, | |||
| camView: 'top'|'bottom'|'front'|'back'|'left'|'right'|string, | |||
| camDistance: number, | |||
| } | |||
| @@ -70,6 +70,10 @@ export {FragmentClippingExtensionPlugin, FragmentClippingMode} from './material/ | |||
| // rendering | |||
| export {VirtualCamerasPlugin} from './rendering/VirtualCamerasPlugin' | |||
| // configurator | |||
| export {MaterialConfiguratorBasePlugin, type MaterialVariations} from './configurator/MaterialConfiguratorBasePlugin' | |||
| export {SwitchNodeBasePlugin, type ObjectSwitchNode} from './configurator/SwitchNodeBasePlugin' | |||
| // extras | |||
| export {HDRiGroundPlugin} from './extras/HDRiGroundPlugin' | |||
| export {Object3DWidgetsPlugin} from './extras/Object3DWidgetsPlugin' | |||
| @@ -0,0 +1,68 @@ | |||
| import {BoxGeometry, CylinderGeometry, HemisphereLight, Light, Mesh, Scene, SphereGeometry, Vector3} from 'three' | |||
| import {IDisposable} from 'ts-browser-helpers' | |||
| import {snapObject} from './snapObject' | |||
| import {IMaterial, ITexture, IWebGLRenderer} from '../../core' | |||
| export class MaterialPreviewGenerator implements IDisposable { | |||
| private _scene: Scene | |||
| private _channel: number | |||
| private _lights: Light[] = [] | |||
| constructor() { | |||
| const scene = new Scene() | |||
| this._channel = 7 | |||
| const hemisphericLight = new HemisphereLight(0xffffff, 0x444444, 1) | |||
| hemisphericLight.position.set(0, 10, 0) | |||
| hemisphericLight.layers.set(this._channel) | |||
| scene.add(hemisphericLight) | |||
| this._lights.push(hemisphericLight) | |||
| this._scene = scene | |||
| } | |||
| dispose() { | |||
| [...this._lights].forEach(light => light.dispose()) | |||
| Object.values(this.shapes).forEach(shape => { | |||
| if (shape.geometry) shape.geometry.dispose() | |||
| }) | |||
| } | |||
| shapes: Record<string, Mesh> = { | |||
| sphere: new Mesh(new SphereGeometry(1)), | |||
| cube: new Mesh(new BoxGeometry(1, 1, 1)), | |||
| cylinder: new Mesh(new CylinderGeometry(0.5, 0.5, 1)), | |||
| } | |||
| // todo: show an overlay when this is happening | |||
| generate(material: IMaterial, renderer: IWebGLRenderer, environment?: ITexture|null, shape = 'sphere'): string { | |||
| const object = this.shapes[shape] || new Mesh(new SphereGeometry(1)) | |||
| object.material = material | |||
| if (!object.geometry.attributes.tangent) object.geometry.computeTangents() // for anisotropy | |||
| this._scene.add(object) | |||
| this._scene.environment = environment ?? null | |||
| const envIntensity = material.envMapIntensity | |||
| // clamp since we have no tonemapping | |||
| if (typeof envIntensity === 'number') { | |||
| material.envMapIntensity = Math.max(envIntensity, 2) | |||
| } | |||
| const snap = snapObject(renderer, object, this._scene, this._channel, new Vector3(0, 0, 1.5)) | |||
| // const snap = snapObject(this.viewer, (material.userData.__appliedMeshes as Set<Mesh>).values().next().value, undefined, this._channel) | |||
| if (typeof envIntensity === 'number') | |||
| material.envMapIntensity = envIntensity | |||
| this._scene.remove(object) | |||
| object.material = undefined as any | |||
| return snap | |||
| } | |||
| } | |||
| @@ -9,6 +9,8 @@ export {threeConstMappings} from './const-mappings' | |||
| export {ObjectPicker} from './ObjectPicker' | |||
| export {autoGPUInstanceMeshes} from './gpu-instancing' | |||
| export {HVBlurHelper} from './HVBlurHelper' | |||
| export {MaterialPreviewGenerator} from './MaterialPreviewGenerator' | |||
| export {snapObject} from './snapObject' | |||
| export {ViewHelper2, type GizmoOrientation, type DomPlacement} from './ViewHelper2' | |||
| // export {} from './constants' | |||
| @@ -0,0 +1,76 @@ | |||
| import {Object3D, PerspectiveCamera, Scene, Vector3} from 'three' | |||
| import {Box3B} from '../math/Box3B' | |||
| import {IWebGLRenderer} from '../../core' | |||
| /** | |||
| * Returns a snapshot of the object. | |||
| * Does a simple render, does not run the full pipeline. | |||
| * | |||
| * Ideally, call this from preRender and object must be in root, for usage see {@link MaterialPreviewGenerator}. | |||
| * @param renderer | |||
| * @param object | |||
| * @param root | |||
| * @param channel | |||
| * @param camOffset | |||
| * @param camera | |||
| */ | |||
| export function snapObject( | |||
| renderer: IWebGLRenderer, | |||
| object: Object3D, | |||
| root?: Scene, | |||
| channel = 7, | |||
| camOffset = new Vector3(0, 0, 1.5), | |||
| camera = new PerspectiveCamera(45, 1, 0.1, 1000) | |||
| ): string { | |||
| const oldVisible = object.visible | |||
| object.visible = true | |||
| const bbox = new Box3B().expandByObject(object, true, true) | |||
| const center = bbox.getCenter(new Vector3()) | |||
| const bboxSize = bbox.getSize(new Vector3()) | |||
| camera.position.copy(center).add(camOffset.clone().multiplyScalar(Math.max(bboxSize.x, bboxSize.y, bboxSize.z))) | |||
| camera.lookAt(center) | |||
| if (object) { | |||
| object.traverseVisible(obj => { | |||
| obj.layers.enable(channel) | |||
| }) | |||
| // console.log((object as any).material) | |||
| } | |||
| if (channel > 0) | |||
| camera.layers.set(channel) | |||
| else | |||
| camera.layers.enableAll() | |||
| // scene.environment = this.viewer.scene.getEnvironment() as any | |||
| renderer.setRenderTarget(null) | |||
| renderer.clear() | |||
| if (typeof renderer.renderWithModes === 'function') { | |||
| renderer.renderWithModes({ | |||
| backgroundRender: false, | |||
| // mainRenderPass: false, | |||
| // screenSpaceRendering: false, | |||
| // shadowMapRender: false, | |||
| }, ()=>{ | |||
| renderer.render(root ?? object, camera) | |||
| }) | |||
| } else { | |||
| renderer.render(root ?? object, camera) | |||
| } | |||
| // renderer.setRenderTarget(target) | |||
| // this._renderer.render(root, camera) | |||
| // todo use webp when possible. | |||
| const snap = renderer.domElement.toDataURL('image/png') | |||
| renderer.clear() | |||
| object.visible = oldVisible | |||
| object.traverseVisible(obj => { | |||
| obj.layers.disable(channel) | |||
| }) | |||
| camera.layers.enableAll() | |||
| return snap | |||
| } | |||