UNPKG

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.

631 lines (576 loc) 23.3 kB
import {deepAccessObject, getOrCall, onChange, serializable, serialize, ValOrFunc} from 'ts-browser-helpers' import { generateUiConfig, generateValueConfig, IUiConfigContainer, uiButton, uiDropdown, UiObjectConfig, UiObjectType, uiSlider, uiToggle, } from 'uiconfig.js' import {generateUUID} from '../three' import {AnimationOptions, Easing} from '@repalash/popmotion' import {EasingFunctions, EasingFunctionType} from './animation' import type {ThreeViewer} from '../viewer' import {ThreeSerialization} from './serialization' import type {AnimationResult, PopmotionPlugin} from '../plugins' import {EventDispatcher} from 'three' const viewerOptions = { 'None': '', ['Background Color']: 'scene.backgroundColor', ['Environment Rotation']: 'scene.environmentRotation.y', ['Environment Intensity']: 'scene.environmentIntensity', // '[Fixed Env Map Direction']: 'scene.fixedEnvMapDirection', ['Camera Position']: 'scene.mainCamera.position', ['Camera Rotation']: 'scene.mainCamera.rotation', ['Camera Zoom']: 'scene.mainCamera.zoom', ['Camera FOV']: 'scene.mainCamera.fov', // '[Directional Light Color']: 'plugins.RandomizedDirectionalLight.light.color', // - todo dosent update shadows every frame // '[Directional Light Direction']: 'plugins.RandomizedDirectionalLight.light.randomParams.direction', // '[Diamond Env Map Rotation']: 'plugins.Diamond.envMap.rotation', ['Tonemap Exposure']: 'plugins.Tonemap.exposure', ['Tonemap Saturation']: 'plugins.Tonemap.saturation', ['Tonemap Contrast']: 'plugins.Tonemap.contrast', ['Tonemap Tone Mapping']: 'plugins.Tonemap.toneMapping', ['SSR Intensity']: 'plugins.SSReflection.passes.ssr.passObject.intensity', ['SSR Boost']: 'plugins.SSReflection.passes.ssr.passObject.boost', ['Chromatic Aberration Intensity']: 'plugins.ChromaticAberration.intensity', ['Film Grain Intensity']: 'plugins.FilmicGrain.intensity', ['Vignette Color']: 'plugins.Vignette.color', ['Vignette Power']: 'plugins.Vignette.power', ['Depth of Field Focal Point']: 'plugins.DepthOfField._focalPointHit', ['Depth of Field Near Far Blur Scale X']: 'plugins.DepthOfField.pass.nearFarBlurScale.x', ['Depth of Field Near Far Blur Scale Y']: 'plugins.DepthOfField.pass.nearFarBlurScale.y', ['Depth of Field Focal Depth Range Y']: 'plugins.DepthOfField.pass.focalDepthRange.y', ['Bloom Intensity']: 'plugins.Bloom.pass.intensity', ['Bloom Radius']: 'plugins.Bloom.pass.radius', ['Bloom Power']: 'plugins.Bloom.pass.power', } export type TUpdaterType = (()=>void) export interface IAnimationObject<V> { access?: string, // dot separated target accessor. 'model.rotation' will give this.model.rotation duration?: number, delay?: number, ease?: Easing|EasingFunctionType; updater?: (TUpdaterType)[]; // dispatch update, default none animSet?: IAnimSet, animSetParallel?: boolean, name?: string, options: AnimationOptions<V>, // to?: V | ((fromVal:V, target: any)=>V), // from?: V, values: V[] offsets?: number[], animate?: (delay?: number, canComplete?: boolean)=>AnimationResult, result?: AnimationResult uiRef?: UiObjectConfig, uiObjectType?: UiObjectType, targetObject?: Record<string, any>, } export type IAnimSet = (IAnimationObject<any>)[] export function extractAnimationKey(o: IAnimationObject<any>, extraGetters?: Record<string, (key: string, acc: string[])=>{tar: any, acc: string[], onChange?: ()=>void}|undefined>) { let acc = Array.from((o.access ?? '').split(/(?<!\\)\./)) // split by dot, but not escaped dots let tar: any = o.targetObject let onChange1: undefined | (()=>void) = undefined const key = acc.pop()?.replace(/\\\./g, '.') // deep access till the last element, then bind if (!key || key.length === 0) return {key: undefined, tar} extraGetters = extraGetters ?? tar?._animGetters // _animGetters are set in AnimationObjectPlugin const getterType = acc.length >= 1 ? acc[0] : undefined const getterName = acc.length >= 2 ? acc[1]?.replace(/\\\./g, '.') : undefined if (extraGetters && getterType && getterType in extraGetters && getterName) { acc = acc.slice(2) const res = extraGetters[getterType](getterName, acc) if (!res) tar = res else { tar = res.tar // acc = acc.slice(res.i + 1) acc = res.acc onChange1 = res.onChange ?? onChange1 } } tar = deepAccessObject(acc, tar) return {key, tar, onChange: onChange1} } export interface AnimationObjectEventMap { 'animationAdd': {animation: AnimationObject} 'animationRemove': {animation: AnimationObject, fromChild: boolean} 'update': object } /** * AnimationObject - An object for containing keyframe-based animation for properties * * AnimationObject extends popmotion and interfaces with the {@link ThreeViewer} to provide a keyframe animation system that can animate any accessible property * on objects, materials, or the viewer itself. It supports complex timing, easing, and serialization. * * It is used in {@link AnimationObjectPlugin}. * * Key Features: * - **Property Access**: Uses dot-notation strings to access nested properties (e.g., 'position.x', 'material.roughness') * - **Keyframe System**: Define multiple keyframes with custom timing and values * - **Easing Support**: Built-in easing functions or custom easing functions * - **Timeline Integration**: Seamlessly works with viewer's global timeline * - **Serialization**: Automatically saves/loads with scene data * - **UI Integration**: Generates UI controls and supports interactive editing * - **Hierarchical**: Can contain child animations for complex choreography * * @example Basic Animation * ```typescript * const anim = new AnimationObject(myObject) * anim.access = 'position.y' * anim.values = [0, 5, 0] * anim.offsets = [0, 0.5, 1] * anim.duration = 2000 * anim.ease = (x: number) => 1 - Math.cos(x * Math.PI / 2) // Custom easeOutSine * anim.updateTarget = true * ``` * * @example Complex Animation with Multiple Keyframes * ```typescript * const colorAnim = new AnimationObject(material) * colorAnim.access = 'color' * colorAnim.values = ['#ff0000', '#00ff00', '#0000ff', '#ff0000'] * colorAnim.offsets = [0, 0.33, 0.66, 1] * colorAnim.duration = 4000 * anim.ease = 'easeInOutSine' * colorAnim.delay = 500 * ``` * */ @serializable('AnimationObject') export class AnimationObject<V = any> extends EventDispatcher<AnimationObjectEventMap> implements IAnimationObject<V>, IUiConfigContainer { uuid = generateUUID() setDirty = () => { // console.log('update') this.updater = [] if (this.options) { this.options.repeatType = this.repeatType this.options.repeat = this.repeat } if (!this._upfn) return if (this.updateScene) this.updater.push(this._upfn.scene) if (this.updateCamera) this.updater.push(this._upfn.camera) if (this.updateViewer) this.updater.push(this._upfn.viewer) if (this.updateTarget) this.updater.push(this._upfn.target) this.dispatchEvent({type: 'update'}) } @serialize() @onChange('setDirty') // @uiInput() name = '' @serialize() // @uiInput() // @uiDropdown('Property', Object.entries(options).map(([label, value])=>({label, value}))) @onChange(AnimationObject.prototype._onAccessChanged) access = '' // dot separated target accessor. 'scene.modelRoot.rotation' will give this.model.rotation // @uiConfig(undefined, {params: (t: AnimationObject)=>({onChange: t.setDirty})}) // @serialize() from?: V // // @uiConfig(undefined, {params: (t: AnimationObject)=>({onChange: t.setDirty})}) // @serialize() // to?: V // | ((fromVal: V, target: any) => V) @serialize() values: V[] = [] @serialize() offsets: number[] = [] @serialize() // @uiConfig() options: AnimationOptions<V> = { // extra options // onUpdate: (v: V)=>{ // console.log(v) // }, // onPlay: ()=>{ // if (this.updateCamera) getOrCall(this.target)?.scene.mainCamera.setInteractions(false, this.uuid) // }, // onStop: ()=>{ // if (this.updateCamera) getOrCall(this.target)?.scene.mainCamera.setInteractions(true, this.uuid) // }, // onComplete: ()=>{ // if (this.updateCamera) getOrCall(this.target)?.scene.mainCamera.setInteractions(true, this.uuid) // }, } @serialize() @uiSlider(undefined, [0, 10000], 1, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') duration = 1000 // ms @serialize() @uiSlider(undefined, [0, 10000], 1, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') delay = 0 /** * Number of times to repeat the animation. * Doesn't work right now */ @serialize() // @uiSlider(undefined, [0, 10], 1, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') repeat = 0 /** * Delay between repeats in milliseconds. * Doesn't work right now */ @serialize() // @uiSlider(undefined, [0, 10], 1, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') repeatDelay = 0 /** * Type of repeat behavior. * - 'loop': repeats the animation from the beginning. * - 'reverse': plays the animation in reverse after it completes. * - 'mirror': plays the animation in reverse after it completes. todo only mirrors the time, not values? * * Doesn't work right now */ @serialize() // @uiDropdown('repeatType', ['loop', 'reverse'/* , 'mirror'*/].map((label:string)=>({label})), (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') repeatType: 'loop' | 'reverse' | 'mirror' = 'reverse' @serialize() @uiDropdown('ease', Object.keys(EasingFunctions).map((label:string)=>({label})), (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') ease: EasingFunctionType = 'easeInOutSine' updater: TUpdaterType[] = [] uiObjectType?: UiObjectType // targetObject?: Record<string, any> get targetObject(): Record<string, any>|undefined { return getOrCall(this.target) ?? this.parent?.targetObject } @serialize() @uiToggle(undefined, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') updateScene = false @serialize() @uiToggle(undefined, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') updateCamera = false @serialize() @uiToggle(undefined, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') updateViewer = false @serialize() // @uiToggle(undefined, (t: AnimationObject)=>({hidden: ()=>!t.access})) @onChange('setDirty') updateTarget = false // uiConfig!: UiObjectConfig @onChange(AnimationObject.prototype._onAccessChanged) target?: Record<string, any> @onChange(AnimationObject.prototype._onAccessChanged) readonly viewer?: ValOrFunc<ThreeViewer|undefined> getViewer(): ThreeViewer|undefined { return this.viewer ? getOrCall(this.viewer) : this.parent?.getViewer() } constructor(target?: object|undefined, viewer?: ValOrFunc<ThreeViewer|undefined>, name = '') { super() this.target = target this.viewer = viewer this.name = name this.dispatchEvent = this.dispatchEvent.bind(this) } fromJSON(data1: any, meta?: any): this { let data = {...data1} if (data.access !== undefined) { // first set access so values are initialized this.access = data.access delete data.access } if (data.from !== undefined) { // old files with to/from data = {...data} data.values = [data.from, data.to] data.offsets = [0, 1] delete data.from delete data.to } ThreeSerialization.Deserialize(data, this, meta, true) this.animSet.map(i=>{ i.parent = this }) return this } private _lastAccess = '' private _lastTarget: any = undefined protected _onAccessChanged() { const tar = this.targetObject if (tar && tar === this.getViewer() && !Object.values(viewerOptions).includes(this.access)) { this.access = '' return } if (tar && tar === this.getViewer() && this.access === 'scene.environment.rotation') { this.access = 'scene.environmentRotation.y' return } if (tar && tar === this.getViewer() && this.access === 'scene.envMapIntensity') { this.access = 'scene.environmentIntensity' return } if (this.access !== this._lastAccess || !this.values.length || this._lastTarget !== tar && tar && this._lastTarget) { this._lastAccess = this.access const lastValues = this.values this.values = [] this.offsets = [] const clone = this._thisValueCloner() if (!clone) { this.refreshUi() return } this.values = [clone(), clone()] // todo improve merge. like it wont work with vectors right now. For that we need to check if primitive type is the same and/or call the .copy() function if (lastValues.length >= 2 && ( typeof lastValues[0] === typeof this.values[0] && (typeof lastValues[0] !== 'object' || (lastValues[0] as any).type && (lastValues[0] as any).type === (this.values[0] as any)?.type) )) this.values = lastValues this.offsets = this.offsets.length === this.values.length ? this.offsets : [0, 1] this.refreshUi() } } private _thisValueCloner() { const {key, tar} = extractAnimationKey(this) const val = tar && key !== undefined ? tar[key] : null return val === undefined || val === null ? null : () => { if (!val) return val if (val.isColor) return '#' + val.getHexString() const res = typeof val.clone === 'function' ? val.clone() : typeof val === 'object' ? {...val} : val return res } } addKeyframe(time: number) { if (this.values.length < 2) { console.warn('AnimationObject: Values not initialized, cannot add keyframe', this) return } const value = this._thisValueCloner() if (!value) { console.warn('AnimationObject: No value to add keyframe for', this) return } const offsetTime = time - this.delay const duration = this.duration const delay = this.delay const offsets = [...this.offsets] const values = [...this.values] let offset = offsetTime / this.duration let index: number let newDuration = duration let newDelay = delay const newValues = [...this.values] const newOffsets = [...this.offsets] if (offset < 0) { const o = -offset offset = 0 for (let i = 0; i < offsets.length; i++) { newOffsets[i] = (offsets[i] + o) / (1 + o) } newDuration = duration - offsetTime newDelay = delay + offsetTime index = 0 } else if (offset > 1) { const o = offset - 1 offset = 1 for (let i = 0; i < offsets.length; i++) { newOffsets[i] = offsets[i] / (1 + o) } newDuration = offsetTime index = offsets.length } else { index = offsets.findIndex(o => o >= offset) if (index < 0) { index = this.offsets.length } else if (this.offsets[index] === offset) { console.warn('AnimationObject: Keyframe already exists at offset', offset, this) return } } const val = value() newValues.splice(index, 0, val) newOffsets.splice(index, 0, offset) const redo = ()=>{ this.duration = newDuration this.delay = newDelay this.values = newValues this.offsets = newOffsets this.setDirty() } const undo = ()=>{ this.duration = duration this.delay = delay this.values = values this.offsets = offsets this.setDirty() } redo() return {undo, redo} } updateKeyframe(index: number) { if (index < 0 || index >= this.values.length) { console.warn('AnimationObject: Invalid keyframe index', index, this) return } const value = this._thisValueCloner() if (!value) { console.warn('AnimationObject: No value to update keyframe for', this) return } const oldValue = this.values[index] const newValue = value() const redo = ()=>{ this.values[index] = newValue this.setDirty() } const undo = ()=>{ this.values[index] = oldValue this.setDirty() } redo() return {undo, redo} } isValueSame(index: number) { if (index < 0 || index >= this.values.length) { console.warn('AnimationObject: Invalid keyframe index', index, this) return false } const value = this._thisValueCloner() if (!value) { console.warn('AnimationObject: No value to update keyframe for', this) return false } const oldValue = this.values[index] const newValue = value() if (oldValue === newValue) return true if (typeof oldValue !== typeof newValue) return false if (typeof oldValue === 'object' && typeof newValue === 'object') { if ((oldValue as any)?.equals) { return (oldValue as any).equals(newValue) } if (newValue?.equals) { return newValue.equals(oldValue) } } return false } refreshUi() { this.setDirty() this.uiConfig?.uiRefresh?.(true, 'postFrame', 1) } parent?: AnimationObject add(o: AnimationObject) { this.animSet.push(o) o.parent = this this.dispatchEvent({type: 'animationAdd', animation: o}) o.addEventListener('update', this.dispatchEvent) o.addEventListener('animationAdd', this.dispatchEvent) o.addEventListener('animationRemove', this.dispatchEvent) this.refreshUi() } remove(o: AnimationObject, fromChild = false) { const idx = this.animSet.indexOf(o) if (idx >= 0) { this.animSet.splice(idx, 1) o.parent = undefined this.dispatchEvent({type: 'animationRemove', animation: o, fromChild}) o.removeEventListener('update', this.dispatchEvent) o.removeEventListener('animationAdd', this.dispatchEvent) o.removeEventListener('animationRemove', this.dispatchEvent) this.refreshUi() } } private _upfn = { viewer: () => this.getViewer()?.setDirty(), renderer: () => this.getViewer()?.renderManager.reset(), scene: () => { this.getViewer()?.scene.setDirty() }, camera: () => this.getViewer()?.scene.mainCamera.setDirty(), target: () => { const t = this.targetObject if (t && typeof t.setDirty === 'function') { t.setDirty({frameFade: false, refreshScene: false, source: 'AnimationObject', key: this.access}) } }, } @uiButton('Animate') animate(delay = 0, canComplete = true): AnimationResult { // console.log('animate', this) if (typeof delay !== 'number' || isNaN(delay)) { // called from ui delay = 0 } if (canComplete && this.result) { console.warn('AnimationObject: Already animating, stopping previous animation') this.stop() } const viewer = this.getViewer() const pop = viewer?.getPlugin<PopmotionPlugin>('PopmotionPlugin') if (!pop) { console.error(`AnimationObject: No ${!viewer ? 'viewer' : 'PopmotionPlugin'}`) const id = generateUUID() return { id, options: this.options, stop: () => {return}, promise: Promise.resolve(id), anims: [], // completed: true, } } return pop.animateObject(this, 0, canComplete, undefined, delay) } result: AnimationResult|undefined // todo during reverse delay should be time - duration // @uiButton('Animate Reverse') // async animateReverse() { // await this.animate(true) // } @uiButton('Stop') stop() { if (!this.result) return this.result.stop() this.result = undefined } @uiButton('Delete') async removeFromParent2() { const viewer = this.getViewer() if (this.parent && viewer) { const confirm = await viewer.dialog.confirm(`Delete: Are you sure you want to delete the animation ${this.name}?`) if (confirm) this.removeFromParent() } } removeFromParent() { if (this.parent) this.parent.remove(this, true) } @serialize() // @uiToggle() animSetParallel = false @serialize() // @uiConfig() animSet: AnimationObject[] = [] // @uiButton('Add Animation') addAnimation() { const o = new AnimationObject(this.target) this.add(o) return o } uiConfig: UiObjectConfig = { type: 'folder', label: ()=>this.name || this.access || 'Animation', children: [ ()=>this.target ? null : { type: 'input', label: 'Property', property: [this, 'access'], children: Object.entries(viewerOptions).map(([label, value])=>({label, value})), }, ()=> this.values.flatMap((val, i)=>[ { ...generateValueConfig(this.values, i + '', undefined, val), label: i === 0 ? 'From' : i === this.values.length - 1 ? 'To' : 'Key ' + i, onChange: ()=>this.setDirty(), }, i > 0 && i < this.values.length - 1 ? { type: 'number', label: 'Offset ' + i, property: [this.offsets, i + ''], bounds: [0, 1], onChange: ()=>this.setDirty(), } : null, ]), generateUiConfig(this), ], uuid: generateUUID(), } }