| @@ -123,6 +123,7 @@ To make changes and run the example, click on the CodePen button on the top righ | |||
| - [DeviceOrientationControlsPlugin](#deviceorientationcontrolsplugin) - Adds a controlsMode to the mainCamera for device orientation controls(gyroscope rotation control). | |||
| - [PointerLockControlsPlugin](#pointerlockcontrolsplugin) - Adds a controlsMode to the mainCamera for pointer lock controls. | |||
| - [ThreeFirstPersonControlsPlugin](#threefirstpersoncontrolsplugin) - Adds a controlsMode to the mainCamera for first person controls from threejs. | |||
| - [GLTFKHRMaterialVariantsPlugin](#gltfkhrmaterialvariantsplugin) - Support using for variants from KHR_materials_variants extension in gltf models. | |||
| - [Rhino3dmLoadPlugin](#rhino3dmloadplugin) - Add support for loading .3dm files | |||
| - [PLYLoadPlugin](#plyloadplugin) - Add support for loading .ply files | |||
| - [STLLoadPlugin](#stlloadplugin) - Add support for loading .stl files | |||
| @@ -3237,6 +3238,44 @@ viewer.scene.mainCamera.controlsMode = 'threeFirstPerson' | |||
| viewer.scene.mainCamera.controlsMode = 'orbit' | |||
| ``` | |||
| ## GLTFKHRMaterialVariantsPlugin | |||
| [//]: # (todo: image) | |||
| [Example](https://threepipe.org/examples/#gltf-khr-material-variants-plugin/) — | |||
| [Source Code](./src/plugins/extras/GLTFKHRMaterialVariantsPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/GLTFKHRMaterialVariantsPlugin.html) | |||
| GLTFKHRMaterialVariantsPlugin adds support for importing and exporting glTF models with the `KHR_materials_variants` extension to load the model with different material variants/combinations. It also provides API and UI to change the current material variant. | |||
| The plugin automatically adds support for the extension when added to the viewer. | |||
| The materials are stored in `object.userData._variantMaterials` and are automatically loaded and saved when using the `GLTFLoader`. | |||
| Sample Usage | |||
| ```typescript | |||
| import {ThreeViewer, GLTFKHRMaterialVariantsPlugin, Mesh2} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const variantsPlugin = viewer.addPluginSync(GLTFKHRMaterialVariantsPlugin) | |||
| // load some model | |||
| await viewer.load(model_url) | |||
| // list of all variants in the model (names and objects) | |||
| console.log(variantsPlugin.variants) | |||
| // change the selected variant | |||
| variantsPlugin.selectedVariant = 'beach' | |||
| ``` | |||
| ### Links | |||
| - https://www.khronos.org/blog/blender-gltf-i-o-support-for-gltf-pbr-material-extensions | |||
| - https://www.khronos.org/blog/streamlining-3d-commerce-with-material-variant-support-in-gltf-assets | |||
| - https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_variants/README.md | |||
| ## Rhino3dmLoadPlugin | |||
| [Example](https://threepipe.org/examples/#rhino3dm-load/) — | |||
| @@ -0,0 +1,36 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>glTF KHR Material Variants 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" | |||
| } | |||
| } | |||
| </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,29 @@ | |||
| import {_testFinish, GLTFKHRMaterialVariantsPlugin, IObject3D, SSAAPlugin, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| msaa: true, | |||
| plugins: [SSAAPlugin, GLTFKHRMaterialVariantsPlugin], | |||
| }) | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| const result = await viewer.load<IObject3D>( | |||
| 'https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Sample-Assets/Models/MaterialsVariantsShoe/glTF/MaterialsVariantsShoe.gltf', | |||
| { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }) | |||
| ui.setupPluginUi(GLTFKHRMaterialVariantsPlugin, {expanded: true}) | |||
| ui.appendChild(result?.getObjectByName('Shoe')?.uiConfig) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -283,6 +283,7 @@ export class MaterialManager<T = ''> extends EventDispatcher<BaseEvent, T> { | |||
| return mat | |||
| } | |||
| // use convertToIMaterial | |||
| // processMaterial(material: IMaterial, options: AnyOptions&{useSourceMaterial?:boolean, materialTemplate?: string, register?: boolean}): IMaterial { | |||
| // if (!material.materialObject) | |||
| // material = (this._processMaterial(material, {...options, register: false}))! | |||
| @@ -0,0 +1,151 @@ | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {GLTFLoader2} from '../../assetmanager' | |||
| import {onChange, serialize} from 'ts-browser-helpers' | |||
| import {IMaterial, IObject3D} from '../../core' | |||
| import {UiObjectConfig} from 'uiconfig.js' | |||
| import { | |||
| GLTFMaterialsVariantsExtensionImport, | |||
| khrMaterialsVariantsGLTF, | |||
| } from './helpers/GLTFMaterialsVariantsExtensionImport' | |||
| import {gltfExporterMaterialsVariantsExtensionExport} from './helpers/GLTFMaterialsVariantsExtensionExport' | |||
| /** | |||
| * This plugin allows to import and export gltf files with KHR_materials_variants extension. | |||
| * The material data is stored in the object userData. The plugin also provides a UI to select the variant. | |||
| * @category Plugin | |||
| */ | |||
| export class GLTFKHRMaterialVariantsPlugin extends AViewerPluginSync<''> { | |||
| public static readonly PluginType = 'GLTFKHRMaterialVariantsPlugin' | |||
| enabled = true | |||
| constructor() { | |||
| super() | |||
| this._loaderCreate = this._loaderCreate.bind(this) | |||
| } | |||
| onAdded(v: ThreeViewer): void { | |||
| super.onAdded(v) | |||
| // v.addEventListener('preRender', this._preRender) | |||
| v.scene.addEventListener('addSceneObject', this._objectAdded) | |||
| v.assetManager.importer.addEventListener('loaderCreate', this._loaderCreate as any) | |||
| v.assetManager.exporter.getExporter('gltf', 'glb')?.extensions?.push(gltfExporterMaterialsVariantsExtensionExport) | |||
| } | |||
| private _loaderCreate({loader}: {loader: GLTFLoader2}) { | |||
| if (!loader.isGLTFLoader2) return | |||
| loader.register((p) => new GLTFMaterialsVariantsExtensionImport(p)) | |||
| } | |||
| onRemove(v: ThreeViewer): void { | |||
| v.scene.removeEventListener('addSceneObject', this._objectAdded) | |||
| v.assetManager.importer.removeEventListener('loaderCreate', this._loaderCreate as any) | |||
| const exportExts = v.assetManager.exporter.getExporter('gltf', 'glb')?.extensions || [] | |||
| const i = exportExts.indexOf(gltfExporterMaterialsVariantsExtensionExport) | |||
| if (i !== -1) exportExts.splice(i, 1) | |||
| this.variants = {} | |||
| return super.onRemove(v) | |||
| } | |||
| variants: Record<string, IObject3D[]> = {} // dont serialize this | |||
| /** | |||
| * The selected variant. Changing this will automatically apply the variant to the objects. | |||
| */ | |||
| @onChange(GLTFKHRMaterialVariantsPlugin.prototype._variantChanged) | |||
| @serialize() | |||
| selectedVariant = '' | |||
| /** | |||
| * If true, the first variant will be applied to the objects when object is added and nothing is selected. | |||
| */ | |||
| @serialize() | |||
| applyFirstVariantOnLoad = true | |||
| private _variantChanged() { | |||
| this.applyVariant(this.selectedVariant || '', true) | |||
| } | |||
| /** | |||
| * Apply the variant to objects. | |||
| * It will also change the `selectedVariant` if `root` is not provided. | |||
| * @param name | |||
| * @param force | |||
| * @param root | |||
| * @param doTraverse | |||
| */ | |||
| applyVariant(name: string, force = false, root?: IObject3D[], doTraverse = true) { | |||
| if (!force && !root && this.selectedVariant === name) return | |||
| if (!name) return | |||
| if (!root) this.selectedVariant = name | |||
| const objects = root ? | |||
| Array.isArray(root) ? root : [root] : | |||
| name ? this.variants[name] || [] : Object.values(this.variants).flat() | |||
| for (const object of objects) { | |||
| const done = new Set() | |||
| const apply = (obj: IObject3D)=>{ | |||
| if (!obj.userData._variantMaterials || done.has(obj)) return | |||
| const va = name ? obj.userData._variantMaterials[name]?.material : obj.userData._originalMaterial | |||
| if (va) { | |||
| if (!obj.userData._originalMaterial) obj.userData._originalMaterial = obj.material | |||
| obj.material = va | |||
| } | |||
| done.add(obj) | |||
| } | |||
| if (doTraverse) object.traverse(apply) | |||
| else apply(object) | |||
| } | |||
| } | |||
| private _objectAdded = (ev: any)=>{ | |||
| const object = ev.object as IObject3D | |||
| if (!object?.isObject3D) return | |||
| if (!this._viewer) return | |||
| object.traverse((obj)=>{ | |||
| if (obj.userData._variantMaterials) { | |||
| for (const val of Object.values(obj.userData._variantMaterials) as any) { | |||
| if (val?.material) val.material = this._viewer?.materialManager.convertToIMaterial(val.material, {}) || val.material | |||
| } | |||
| } | |||
| const d = obj.userData?.__importData?.[khrMaterialsVariantsGLTF] | |||
| if (!d) return | |||
| const names = d.names || [] as string[] | |||
| for (const name of names) { | |||
| if (!this.variants[name]) this.variants[name] = [] | |||
| this.variants[name].push(obj) | |||
| } | |||
| delete obj.userData.__importData[khrMaterialsVariantsGLTF] | |||
| }) | |||
| if (!this.selectedVariant && this.applyFirstVariantOnLoad) { | |||
| this.selectedVariant = Object.keys(this.variants)[0] || '' | |||
| } | |||
| this.uiConfig.uiRefresh?.() | |||
| return | |||
| } | |||
| uiConfig: UiObjectConfig = { | |||
| type: 'folder', | |||
| label: 'KHR Material Variants', | |||
| children: [ | |||
| ()=>({ | |||
| children: [null, ...Object.keys(this.variants)].map((label) => !label ? {label: 'none', value: ''} : {label}), | |||
| type: 'dropdown', | |||
| label: 'Variant', | |||
| property: [this, 'selectedVariant'], | |||
| }), | |||
| ], | |||
| } | |||
| } | |||
| declare module './../../core/IObject'{ | |||
| interface IObject3DUserData{ | |||
| /** | |||
| * Starts with `_` so that its not saved in gltf, but saved in json. | |||
| */ | |||
| _variantMaterials?: Record<string, {material: IMaterial}> | |||
| _originalMaterial?: IObject3D['material'] | |||
| } | |||
| } | |||
| @@ -0,0 +1,122 @@ | |||
| /** | |||
| * Materials variants extension | |||
| * Modified from https://github.com/takahirox/three-gltf-extensions/blob/main/exporters/KHR_materials_variants/KHR_materials_variants_exporter.js | |||
| * | |||
| * Specification: https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_variants | |||
| */ | |||
| import {Material, Mesh, Object3D} from 'three' | |||
| import {khrMaterialsVariantsGLTF} from './GLTFMaterialsVariantsExtensionImport' | |||
| import {GLTFWriter2} from '../../../assetmanager' | |||
| /** | |||
| * @param object {THREE.Object3D} | |||
| * @return {boolean} | |||
| */ | |||
| const compatibleObject = (object: Object3D) => { | |||
| return (object as Mesh).material !== undefined && // easier than (!object.isMesh && !object.isLine && !object.isPoints) | |||
| object.userData && // just in case | |||
| object.userData._variantMaterials && | |||
| !!Object.values(object.userData._variantMaterials).filter(m => compatibleMaterial((m as Mesh)?.material as any)) | |||
| } | |||
| /** | |||
| * @param material {THREE.Material} | |||
| * @return {boolean} | |||
| */ | |||
| const compatibleMaterial = (material: Material) => { | |||
| // @TODO: support multi materials? | |||
| return material && material.isMaterial && !Array.isArray(material) | |||
| } | |||
| export class GLTFExporterMaterialsVariantsExtensionExport { | |||
| name = khrMaterialsVariantsGLTF | |||
| variantNames: string[] = [] | |||
| constructor(public writer: GLTFWriter2) { | |||
| } | |||
| beforeParse(objects: Object3D[]) { | |||
| // Find all variant names and store them to the table | |||
| const variantNameTable = new Set<string>() | |||
| for (const object of objects) { | |||
| object.traverse(o => { | |||
| if (!compatibleObject(o)) { | |||
| return | |||
| } | |||
| const variantMaterials = o.userData._variantMaterials | |||
| for (const variantName in variantMaterials) { | |||
| const variantMaterial = variantMaterials[variantName] | |||
| // Ignore unloaded variant materials | |||
| if (compatibleMaterial(variantMaterial.material)) { | |||
| variantNameTable.add(variantName) | |||
| } | |||
| } | |||
| }) | |||
| } | |||
| // We may want to sort? | |||
| variantNameTable.forEach(name => this.variantNames.push(name)) | |||
| } | |||
| writeMesh(mesh: Mesh, meshDef: any) { | |||
| if (!compatibleObject(mesh)) { | |||
| return | |||
| } | |||
| const userData = mesh.userData | |||
| const variantMaterials = userData._variantMaterials | |||
| const mappingTable: Record<number, any> = {} | |||
| for (const variantName in variantMaterials) { | |||
| const variantMaterialInstance = variantMaterials[variantName].material | |||
| if (!compatibleMaterial(variantMaterialInstance)) { | |||
| continue | |||
| } | |||
| const variantIndex = this.variantNames.indexOf(variantName) // Shouldn't be -1 | |||
| const materialIndex = this.writer.processMaterial(variantMaterialInstance)! | |||
| if (!mappingTable[materialIndex]) { | |||
| mappingTable[materialIndex] = { | |||
| material: materialIndex, | |||
| variants: [], | |||
| } | |||
| } | |||
| mappingTable[materialIndex].variants.push(variantIndex) | |||
| } | |||
| const mappingsDef = Object.values(mappingTable) | |||
| .map(m => {return (m.variants as number[]).sort((a, b) => a - b) && m}) | |||
| .sort((a, b) => a.material - b.material) | |||
| if (mappingsDef.length === 0) { | |||
| return | |||
| } | |||
| const originalMaterialIndex = compatibleMaterial(userData._originalMaterial) | |||
| ? this.writer.processMaterial(userData._originalMaterial) ?? -1 : -1 | |||
| for (const primitiveDef of meshDef.primitives) { | |||
| // Override primitiveDef.material with original material. | |||
| if (originalMaterialIndex >= 0) { | |||
| primitiveDef.material = originalMaterialIndex | |||
| } | |||
| primitiveDef.extensions = primitiveDef.extensions || {} | |||
| primitiveDef.extensions[this.name] = {mappings: mappingsDef} | |||
| } | |||
| } | |||
| afterParse(_input: any) { | |||
| if (this.variantNames.length === 0) { | |||
| return | |||
| } | |||
| const root = this.writer.json | |||
| root.extensions = root.extensions || {} | |||
| const variantsDef = this.variantNames.map(n => {return {name: n}}) | |||
| root.extensions[this.name] = {variants: variantsDef} | |||
| this.writer.extensionsUsed[this.name] = true | |||
| } | |||
| } | |||
| export function gltfExporterMaterialsVariantsExtensionExport(writer: GLTFWriter2) { | |||
| return new GLTFExporterMaterialsVariantsExtensionExport(writer) | |||
| } | |||
| @@ -0,0 +1,155 @@ | |||
| /** | |||
| * Materials variants extension | |||
| * Modified from https://github.com/takahirox/three-gltf-extensions/blob/main/loaders/KHR_materials_variants/KHR_materials_variants.js | |||
| * | |||
| * Specification: https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_variants | |||
| */ | |||
| import {Material, Mesh, Object3D} from 'three' | |||
| import {GLTFParser} from 'three/examples/jsm/loaders/GLTFLoader' | |||
| import {IObject3D} from '../../../core' | |||
| // export type OnUpdateType = ((arg0: Mesh, arg1: Material, arg2: any) => void) | null | |||
| /** | |||
| * KHR_materials_variants specification allows duplicated variant names | |||
| * but it makes handling the extension complex. | |||
| * We ensure tha names and make it easier. | |||
| * If you want to export the extension with the original names | |||
| * you are recommended to write GLTFExporter plugin to restore the names. | |||
| * | |||
| * @param variantNames {Array<string>} | |||
| * @return {Array<string>} | |||
| */ | |||
| const ensureUniqueNames = (variantNames: string[]): string[] => { | |||
| const uniqueNames = [] | |||
| const knownNames = new Set() | |||
| for (const name of variantNames) { | |||
| let uniqueName = name | |||
| let suffix = 0 | |||
| // @TODO: An easy solution. | |||
| // O(N^2) in the worst scenario where N is variantNames.length. | |||
| // Fix me if needed. | |||
| while (knownNames.has(uniqueName)) { | |||
| uniqueName = name + '.' + ++suffix | |||
| } | |||
| knownNames.add(uniqueName) | |||
| uniqueNames.push(uniqueName) | |||
| } | |||
| return uniqueNames | |||
| } | |||
| /** | |||
| * Convert mappings array to table object to make handling the extension easier. | |||
| * | |||
| * @param extensionDef {glTF.meshes[n].primitive.extensions.KHR_materials_variants} | |||
| * @param variantNames {Array<string>} Required to be unique names | |||
| * @return {Object} | |||
| */ | |||
| const mappingsArrayToTable = (extensionDef: any, variantNames: string[]): any => { | |||
| const table: any = {} | |||
| for (const mapping of extensionDef.mappings) { | |||
| for (const variant of mapping.variants) { | |||
| table[variantNames[variant]] = { | |||
| material: null, | |||
| gltfMaterialIndex: mapping.material, | |||
| } | |||
| } | |||
| } | |||
| return table | |||
| } | |||
| /** | |||
| * @param object {THREE.Object3D} | |||
| * @return {boolean} | |||
| */ | |||
| const compatibleObject = (object: Object3D) => { | |||
| return (object as Mesh).material !== undefined && // easier than (!object.isMesh && !object.isLine && !object.isPoints) | |||
| object.userData && // just in case | |||
| object.userData._variantMaterials | |||
| } | |||
| export const khrMaterialsVariantsGLTF = 'KHR_materials_variants' | |||
| export class GLTFMaterialsVariantsExtensionImport { | |||
| name = khrMaterialsVariantsGLTF | |||
| constructor(public parser: GLTFParser) { | |||
| } | |||
| // Note that the following properties will be overridden even if they are pre-defined | |||
| // - mesh.userData._variantMaterials | |||
| async afterRoot(gltf: any) { | |||
| const parser = this.parser | |||
| const json = parser.json | |||
| if (!json.extensions || !json.extensions[this.name]) return | |||
| const extensionDef = json.extensions[this.name] | |||
| const variantsDef = extensionDef.variants || [] | |||
| const variants = ensureUniqueNames(variantsDef.map((v: any) => v.name)) | |||
| // Save the _variantMaterials data under associated mesh.userData | |||
| for (const scene of gltf.scenes) { | |||
| // Save the variants data under associated mesh.userData | |||
| (scene as IObject3D).traverse(object => { | |||
| const association = parser.associations.get(object) | |||
| if (!association || association.meshes === undefined || (association as any).primitives === undefined) { | |||
| return | |||
| } | |||
| const meshDef = json.meshes[association.meshes] | |||
| const primitiveDef = meshDef.primitives[(association as any).primitives] | |||
| const extensionsDef = primitiveDef.extensions | |||
| if (!extensionsDef || !extensionsDef[this.name]) { | |||
| return | |||
| } | |||
| // object should be Mesh | |||
| object.userData._variantMaterials = mappingsArrayToTable(extensionsDef[this.name], variants) | |||
| }) | |||
| } | |||
| // gltf.userData.variants = variants | |||
| /** | |||
| * @param object {THREE.Mesh} | |||
| * @return {Promise} | |||
| */ | |||
| const ensureLoadVariants = async(object: Mesh) => { | |||
| const currentMaterial = object.material as Material | |||
| const variantMaterials = object.userData._variantMaterials | |||
| const pending = [] | |||
| for (const variantName in variantMaterials) { | |||
| const variantMaterial = variantMaterials[variantName] | |||
| if (variantMaterial.material) { | |||
| continue | |||
| } | |||
| const materialIndex = variantMaterial.gltfMaterialIndex | |||
| pending.push(parser.getDependency('material', materialIndex).then(material => { | |||
| object.material = material | |||
| parser.assignFinalMaterial(object) | |||
| variantMaterials[variantName].material = object.material | |||
| // delete variantMaterials[variantName].gltfMaterialIndex // todo; | |||
| })) | |||
| } | |||
| return Promise.all(pending).then(() => { | |||
| object.material = currentMaterial | |||
| }) | |||
| } | |||
| await Promise.all(gltf.scenes.map(async(scene: Object3D) => { | |||
| const pending: Promise<any>[] = [] | |||
| scene.traverse(o => compatibleObject(o) && pending.push(ensureLoadVariants(o as Mesh))) | |||
| if (!scene.userData.__importData) scene.userData.__importData = {} | |||
| scene.userData.__importData[khrMaterialsVariantsGLTF] = { | |||
| names: variants, | |||
| } | |||
| return Promise.all(pending) | |||
| })) | |||
| } | |||
| } | |||
| @@ -74,3 +74,4 @@ export {Object3DGeneratorPlugin} from './extras/Object3DGeneratorPlugin' | |||
| export {ContactShadowGroundPlugin} from './extras/ContactShadowGroundPlugin' | |||
| export {SimplifyModifierPlugin} from './extras/SimplifyModifierPlugin' | |||
| export {MeshOptSimplifyModifierPlugin} from './extras/MeshOptSimplifyModifierPlugin' | |||
| export {GLTFKHRMaterialVariantsPlugin} from './extras/GLTFKHRMaterialVariantsPlugin' | |||