| <li><a href="./simplify-modifier-plugin/">Simplify Modifier Plugin </a></li> | <li><a href="./simplify-modifier-plugin/">Simplify Modifier Plugin </a></li> | ||||
| <li><a href="./meshopt-simplify-modifier-plugin/">MeshOpt Simplify Modifier Plugin </a></li> | <li><a href="./meshopt-simplify-modifier-plugin/">MeshOpt Simplify Modifier Plugin </a></li> | ||||
| </ul> | </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> | <h2 class="category">Import</h2> | ||||
| <ul> | <ul> | ||||
| <li><a href="./fbx-load/">FBX Load </a></li> | <li><a href="./fbx-load/">FBX Load </a></li> | ||||
| <li><a href="./object3d-generator-plugin/">Object3D Generator Plugin <br/>(Lights, Cameras)</a></li> | <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="./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="./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="./geometry-uv-preview/">Geometry UV Preview Plugin </a></li> | ||||
| <li><a href="./parallel-asset-import/">Parallel Asset Import </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> | <li><a href="./obj-to-glb/">Convert OBJ to GLB </a></li> |
| <!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> |
| 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) |
| <!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> |
| 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) |
| "@threepipe/plugin-extra-importers": "./../../plugins/extra-importers/dist/index.mjs", | "@threepipe/plugin-extra-importers": "./../../plugins/extra-importers/dist/index.mjs", | ||||
| "@threepipe/plugin-blend-importer": "./../../plugins/blend-importer/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-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" | "@threepipe/plugin-gaussian-splatting": "./../../plugins/gaussian-splatting/dist/index.mjs" | ||||
| } | } | ||||
| } | } |
| import {extraImportPlugins} from '@threepipe/plugin-extra-importers' | import {extraImportPlugins} from '@threepipe/plugin-extra-importers' | ||||
| import {GeometryGeneratorPlugin} from '@threepipe/plugin-geometry-generator' | import {GeometryGeneratorPlugin} from '@threepipe/plugin-geometry-generator' | ||||
| import {GaussianSplattingPlugin} from '@threepipe/plugin-gaussian-splatting' | import {GaussianSplattingPlugin} from '@threepipe/plugin-gaussian-splatting' | ||||
| import {MaterialConfiguratorPlugin, SwitchNodePlugin} from '@threepipe/plugin-configurator' | |||||
| async function init() { | async function init() { | ||||
| DeviceOrientationControlsPlugin, | DeviceOrientationControlsPlugin, | ||||
| PointerLockControlsPlugin, | PointerLockControlsPlugin, | ||||
| ThreeFirstPersonControlsPlugin, | ThreeFirstPersonControlsPlugin, | ||||
| // InteractionPromptPlugin, // todo disable when not in Viewer tab, like in webgi | |||||
| new MeshOptSimplifyModifierPlugin(false), // will auto-initialize on first use. | new MeshOptSimplifyModifierPlugin(false), // will auto-initialize on first use. | ||||
| // new BasicSVGRendererPlugin(false, true), | // new BasicSVGRendererPlugin(false, true), | ||||
| ...extraImportPlugins, | ...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) | const rt = viewer.getOrAddPluginSync(RenderTargetPreviewPlugin) | ||||
| rt.addTarget({texture: viewer.getPlugin(GBufferPlugin)?.normalDepthTexture}, 'normalDepth') | rt.addTarget({texture: viewer.getPlugin(GBufferPlugin)?.normalDepthTexture}, 'normalDepth') | ||||
| rt.addTarget({texture: viewer.getPlugin(GBufferPlugin)?.flagsTexture}, 'gBufferFlags') | rt.addTarget({texture: viewer.getPlugin(GBufferPlugin)?.flagsTexture}, 'gBufferFlags') | ||||
| ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | ||||
| ['Post-processing']: [TonemapPlugin, ProgressivePlugin, SSAOPlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | ['Post-processing']: [TonemapPlugin, ProgressivePlugin, SSAOPlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | ||||
| ['Export']: [CanvasSnapshotPlugin], | ['Export']: [CanvasSnapshotPlugin], | ||||
| ['Configuration']: [GLTFKHRMaterialVariantsPlugin], | |||||
| ['Configurator']: [MaterialConfiguratorPlugin, SwitchNodePlugin, GLTFKHRMaterialVariantsPlugin], | |||||
| ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | ||||
| ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | ||||
| ['Debug']: [RenderTargetPreviewPlugin], | ['Debug']: [RenderTargetPreviewPlugin], |
| { | |||||
| "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" | |||||
| } | |||||
| } | |||||
| } | |||||
| } |
| { | |||||
| "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" | |||||
| } | |||||
| } |
| .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 */ | |||||
| } |
| 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 = '' | |||||
| } | |||||
| } |
| 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() | |||||
| } | |||||
| } | |||||
| 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() | |||||
| }, | |||||
| }) | |||||
| } | |||||
| 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 | |||||
| } | |||||
| } |
| 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 |
| export {MaterialConfiguratorPlugin} from './MaterialConfiguratorPlugin' | |||||
| export {GridItemListPlugin} from './GridItemListPlugin' | |||||
| export {GridItemList} from './GridItemList' | |||||
| export {SwitchNodePlugin} from './SwitchNodePlugin' |
| { | |||||
| "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" | |||||
| ] | |||||
| } |
| { | |||||
| "extends": [ | |||||
| "../../typedoc.json" | |||||
| ], | |||||
| "entryPoints": [ | |||||
| "src/index.ts" | |||||
| ], | |||||
| "name": "Threepipe Configurator Plugins", | |||||
| "readme": "none" | |||||
| } |
| 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. | |||||
| }, | |||||
| }), | |||||
| ], | |||||
| }) |
| 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 | |||||
| } |
| 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, | |||||
| } |
| // rendering | // rendering | ||||
| export {VirtualCamerasPlugin} from './rendering/VirtualCamerasPlugin' | export {VirtualCamerasPlugin} from './rendering/VirtualCamerasPlugin' | ||||
| // configurator | |||||
| export {MaterialConfiguratorBasePlugin, type MaterialVariations} from './configurator/MaterialConfiguratorBasePlugin' | |||||
| export {SwitchNodeBasePlugin, type ObjectSwitchNode} from './configurator/SwitchNodeBasePlugin' | |||||
| // extras | // extras | ||||
| export {HDRiGroundPlugin} from './extras/HDRiGroundPlugin' | export {HDRiGroundPlugin} from './extras/HDRiGroundPlugin' | ||||
| export {Object3DWidgetsPlugin} from './extras/Object3DWidgetsPlugin' | export {Object3DWidgetsPlugin} from './extras/Object3DWidgetsPlugin' |
| 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 | |||||
| } | |||||
| } |
| export {ObjectPicker} from './ObjectPicker' | export {ObjectPicker} from './ObjectPicker' | ||||
| export {autoGPUInstanceMeshes} from './gpu-instancing' | export {autoGPUInstanceMeshes} from './gpu-instancing' | ||||
| export {HVBlurHelper} from './HVBlurHelper' | export {HVBlurHelper} from './HVBlurHelper' | ||||
| export {MaterialPreviewGenerator} from './MaterialPreviewGenerator' | |||||
| export {snapObject} from './snapObject' | |||||
| export {ViewHelper2, type GizmoOrientation, type DomPlacement} from './ViewHelper2' | export {ViewHelper2, type GizmoOrientation, type DomPlacement} from './ViewHelper2' | ||||
| // export {} from './constants' | // export {} from './constants' |
| 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 | |||||
| } |