threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
474 lines (433 loc) • 19.8 kB
text/typescript
import {AViewerPluginEventMap, AViewerPluginSync, ThreeViewer} from '../../viewer'
import {PickingPlugin} from '../interaction/PickingPlugin'
import {escapeRegExp, getOrCall, imageBitmapToBase64, makeColorSvgCircle, serialize} from 'ts-browser-helpers'
import {UiObjectConfig} from 'uiconfig.js'
import {IMaterial, IObject3D, PhysicalMaterial} from '../../core'
import {MaterialPreviewGenerator} from '../../three'
import {Color} from 'three'
import {AnimationResult, PopmotionPlugin} from '../animation/PopmotionPlugin'
import {FrameFadePlugin} from '../pipeline/FrameFadePlugin'
import {AnimateTime} from '../../utils'
/**
* 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<{'refreshUi': object} & AViewerPluginEventMap> {
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._preFrame = this._preFrame.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.
viewer.forPlugin(PickingPlugin, (p)=>{
this._picking = p
this._picking?.addEventListener('selectedObjectChanged', this._refreshUiConfig)
}, ()=>{
this._picking?.removeEventListener('selectedObjectChanged', this._refreshUiConfig)
this._picking = undefined
})
this._previewGenerator = new MaterialPreviewGenerator()
viewer.addEventListener('preFrame', this._refreshUi)
viewer.addEventListener('preFrame', this._preFrame)
}
/**
* Apply all variations(by selected index or first item) when a config is loaded
*/
applyOnLoad = true
applyOnLoadForce = false
/**
* Reapply all selected variations again.
* Useful when a model or config is loaded or changed and the variations are not applied in the model.
* It is automatically called when the config is loaded if `applyOnLoad` is true.
*/
reapplyAll() {
this.variations.forEach(async v => {
if (v.selectedIndex === undefined) return // nothing selected
this.applyVariation(v, v.selectedIndex)
})
}
fromJSON(data: any, meta?: any): this | Promise<this | null> | null {
this.variations = []
if (!super.fromJSON(data, meta)) return null // it's not a promise
if (this.applyOnLoadForce && data.applyOnLoad !== false ||
data.applyOnLoad !== undefined && 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)
viewer.removeEventListener('preFrame', this._preFrame)
this._picking = undefined
return super.onRemove(viewer)
}
findVariation(mapping?: string): MaterialVariations|undefined {
return mapping ? this.variations.find(v => {
if (v.regex ?? true) return mapping.match(typeof v.uuid === 'string' ? '^' + v.uuid + '$' : v.uuid) !== null
else return v.uuid === mapping
}) : undefined
}
getSelectedVariation(): MaterialVariations|undefined {
const selected = this._selectedMaterial()
if (!selected) return undefined
const v = this.findVariation(selected.uuid) || this.findVariation(selected.name)
if (v && v.regex === undefined) v.regex = true // required for tweakpane and old files, it cannot be undefined
return v
}
/**
* Apply a material variation based on index or uuid.
* @param variations
* @param matUuidOrIndex
* @param setSelectedIndex - default true, to be used with animation
* @param time - optional data to animate(lerp) from current value to the target material.
*/
applyVariation(variations: MaterialVariations, matUuidOrIndex: string|number, setSelectedIndex?: boolean, time?: AnimateTime & {from?: string | number}): boolean {
const m = this._viewer?.materialManager
if (!m) return false
const material = this.findMaterialVariation(matUuidOrIndex, variations)
if (!material) return false
setSelectedIndex && (variations.selectedIndex = variations.materials.indexOf(material))
const fromMaterial = time?.from !== undefined ? this.findMaterialVariation(time.from, variations) : undefined
return m.applyMaterial(material, variations.uuid, variations.regex ?? true, time?.from !== undefined ? {...time, from: fromMaterial} : (time as AnimateTime))
}
findMaterialVariation(matUuidOrIndex: string | number, variations: MaterialVariations) {
return typeof matUuidOrIndex === 'string' ?
variations.materials.find(m1 => m1.uuid === matUuidOrIndex) :
variations.materials[matUuidOrIndex]
}
async applyVariationAnimate(variations: MaterialVariations, matUuidOrIndex: string|number, duration = 500): Promise<void> {
if (variations._animation) {
variations._animation.stop()
}
const popmotion = this._viewer?.getPlugin(PopmotionPlugin)
if (!popmotion) {
throw new Error('MaterialConfiguratorBasePlugin - PopmotionPlugin is required for animation, please add it to the viewer.')
}
this._viewer?.getPlugin(FrameFadePlugin)?.disable(MaterialConfiguratorBasePlugin.PluginType)
const anim = popmotion.animateNumber({
duration,
onUpdate: (v, dv) => {
this.applyVariation(variations, matUuidOrIndex, true, {t: v, dt: dv})
},
onComplete: () => {
this.applyVariation(variations, matUuidOrIndex, true, {t: 1, dt: 0})
},
onEnd: ()=>{
if (variations._animation !== anim) return
variations._animation = undefined
},
})
variations._animation = anim
await variations._animation?.promise
this._viewer?.getPlugin(FrameFadePlugin)?.enable(MaterialConfiguratorBasePlugin.PluginType)
}
/**
* 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.isDisabled() || !this._viewer || getOrCall(this.uiConfig.hidden)) return
this.dispatchEvent({type: 'refreshUi'})
this._uiNeedRefresh = true
}
private _refreshUiConfig() {
if (this.isDisabled()) return
this.uiConfig.uiRefresh?.(true, 'postFrame', 500) // don't call this.refreshUi here
}
// must be called from preFrame
protected async _refreshUi(): Promise<boolean> {
if (this.isDisabled()) return false
if (!this._viewer || !this._uiNeedRefresh) return false
this._uiNeedRefresh = false
this._refreshUiConfig()
return true
}
protected _preFrame() {
if (this.isDisabled()) return false
if (!this._viewer?.timeline.shouldRun() || !this.variations.length) return false
const time = this._viewer?.timeline.time
const delta = this._viewer?.timeline.delta || 0
const looping = this._viewer?.timeline.resetOnEnd ?? false
let applied = false
for (const variation of this.variations) {
if (!variation.timeline?.length) continue
const selected = variation.selectedIndex
const sortedTimeline = variation.timeline
.sort((a, b) => -a.time + b.time)
const selectedTime = sortedTimeline.find(t => t.time <= time)
const selectedItemI = selectedTime ? sortedTimeline.indexOf(selectedTime) : -1
const previousTime =
selectedItemI < sortedTimeline.length - 1 && selectedItemI >= 0 ?
sortedTimeline[selectedItemI + 1] : // next item is the previous item because of sorting.
looping && selectedItemI > 0 ? sortedTimeline[0] : undefined
const isSeeking = !this._viewer?.timeline.running
if (selectedTime) {
const notSelected = typeof selected === 'undefined' ||
selectedTime.index !== selected && (typeof selected !== 'number' || selectedTime.index !== variation.materials[selected]?.uuid)
if (isSeeking || notSelected) {
const start = selectedTime.time
const duration = selectedTime.duration ?? 0.5
let t = duration < 1e-6 ? 1 : (time - start) / duration
let dt = duration < 1e-6 ? 0 : delta / duration
// if (t <= 1 || isSeeking) { // seeking if not running
if (t > 1) {
t = 1
}
if (dt < 1e-6)
dt = 1.0 / 60
// dt = 1. - t // if delta is too small, we can assume we are at the end of the timeline (like when dragging) (dragging uses from value now)
// dt = (1. - t) / 2
// console.log(selectedItemI, previousTime?.index, t, dt)
this.applyVariation(variation, selectedTime.index, t >= 1. - 0.00001, {
t, dt,
from: isSeeking ? previousTime?.index : undefined,
rm: this._viewer?.renderManager,
})
applied = true
// }
}
}
}
return applied
}
variations: MaterialVariations[] = []
private _selectedMaterial = () => {
const selected = this._picking?.getSelectedObject()
if (!selected) return undefined
if ((selected as IMaterial).isMaterial) return selected as IMaterial
else {
const mat = ((selected as IObject3D)?.material || undefined) as IMaterial | undefined
if (Array.isArray(mat)) return mat[0]
return mat
}
}
protected _uicShowAllVariations = false
createVariationsUiConfig(v?: MaterialVariations) {
// if(!v) v = this.getSelectedVariation()
if (!v) return undefined
return {
type: 'folder',
label: v.title,
uuid: v.uuid,
children: [
{
type: 'input',
label: 'mapping',
property: () => [v, 'uuid'],
onChange: async() => this.refreshUi(),
},
{
type: 'input',
label: 'title',
property: () => [v, 'title'],
onChange: async() => this.refreshUi(),
},
{
type: 'dropdown',
label: 'Preview Type',
property: () => [v, '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,
})),
},
{
type: 'checkbox',
label: 'regex mapping',
// hidden: () => !this._selectedMaterial()/* || this.getSelectedVariation()?.uuid.match(/[.*+?[\](){}^$|\\]/) === null*/,
property: () => [v, 'regex'],
onChange: async() => this.refreshUi(),
},
...v.materials.map(m => {
return m.uiConfig ? Object.assign(m.uiConfig, {expanded: false}) : {}
}),
],
}
}
uiConfig: UiObjectConfig = {
label: 'Material Configurator',
type: 'folder',
// expanded: true,
children: [
() => [
{
type: 'input',
label: 'uuid',
property: [this._selectedMaterial(), 'uuid'],
hidden: () => !this._selectedMaterial(),
disabled: true,
},
this.createVariationsUiConfig(this.getSelectedVariation()) ?? (this._uicShowAllVariations ? {} : {
type: 'button',
label: 'Select a material to see or add variations',
readOnly: true,
}),
this._uicShowAllVariations && !this._selectedMaterial() ? this.variations.map(v => this.createVariationsUiConfig(v)) : [],
{
type: 'button',
label: 'Clear variations',
hidden: () => !this.getSelectedVariation(),
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.getSelectedVariation(),
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))
},
},
{
type: 'checkbox',
label: 'Show All',
hidden: () => this._selectedMaterial(),
property: () => [this, '_uicShowAllVariations'],
onChange: async() => this.uiConfig?.uiRefresh?.(),
},
],
],
}
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, variationKey?: string, cloneMaterial = true) {
const clone = cloneMaterial && material?.clone ? material.clone() : material
if (material && clone) {
let variation = this.findVariation(variationKey ?? material.uuid)
if (!variation && !variationKey && material.name.length > 0) variation = this.findVariation(material.name)
if (!variation) {
variation = this.createVariation(material, variationKey)
}
variation.materials.push(clone)
this.refreshUi()
}
}
createVariation(material: IMaterial, variationKey?: string) {
this.variations.push({
uuid: variationKey ?? material.name.length > 0 ? escapeRegExp(material.name) : material.uuid,
title: material.name.length > 0 ? material.name : 'No Name',
preview: 'generate:sphere',
materials: [],
regex: true,
})
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
}[]
/**
* Whether to use regex to match the material name.
* @default true
*/
regex?: boolean
selectedIndex?: number | string
/**
* Keyframes for the viewer timeline animation
*/
timeline?: {
time: number,
index: number|string,
duration?: number,
}[]
_animation?: AnimationResult
}