| @@ -2192,7 +2192,15 @@ const cube = viewer.scene.getObjectByName('cube'); | |||
| const popmotion = viewer.addPluginSync(new PopmotionPlugin()) | |||
| // Move the object cube 1 unit up. | |||
| const anim = popmotion.animate({ | |||
| const anim = popmotion.animateTarget(cube, 'position', { | |||
| to: cube.position.clone().add(new Vector3(0,1,0)), | |||
| duration: 500, // ms | |||
| onComplete: () => isMovedUp = true, | |||
| onStop: () => throw(new Error('Animation stopped')), | |||
| }) | |||
| // Alternatively, set the property directly in onUpdate. | |||
| const anim1 = popmotion.animate({ | |||
| from: cube.position.y, | |||
| to: cube.position.y + 1, | |||
| duration: 500, // ms | |||
| @@ -2200,7 +2208,7 @@ const anim = popmotion.animate({ | |||
| cube.position.setY(v) | |||
| cube.setDirty() | |||
| }, | |||
| onComplete: () => isMovedUp = !isMovedUp, | |||
| onComplete: () => isMovedUp = true, | |||
| onStop: () => throw(new Error('Animation stopped')), | |||
| }) | |||
| @@ -1,4 +1,4 @@ | |||
| import {_testFinish, BoxGeometry, Color, Mesh, PhysicalMaterial, PopmotionPlugin, ThreeViewer} from 'threepipe' | |||
| import {_testFinish, BoxGeometry, Color, Mesh, PhysicalMaterial, PopmotionPlugin, ThreeViewer, Vector3} from 'threepipe' | |||
| import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js' | |||
| async function init() { | |||
| @@ -20,16 +20,11 @@ async function init() { | |||
| createSimpleButtons({ | |||
| ['Move Up/Down']: async(btn) => { | |||
| btn.disabled = true | |||
| await popmotion.animateAsync({ | |||
| from: cube.position.y, | |||
| to: cube.position.y + (isMovedUp ? -1 : 1), | |||
| await popmotion.animateTargetAsync(cube, 'position', { | |||
| to: cube.position.clone().add(new Vector3(0, isMovedUp ? -1 : 1, 0)), | |||
| duration: 500, // ms | |||
| onUpdate: (v) => { | |||
| cube.position.setY(v) | |||
| cube.setDirty() | |||
| }, | |||
| onComplete: () => isMovedUp = !isMovedUp, | |||
| }) | |||
| }) // setDirty is automatically called on the cube since it's the target | |||
| btn.disabled = false | |||
| }, | |||
| ['Rotate +90deg']: async(btn) => { | |||
| @@ -1,12 +1,12 @@ | |||
| { | |||
| "name": "@threepipe/plugins-extra-importers", | |||
| "version": "0.1.0", | |||
| "version": "0.1.1", | |||
| "lockfileVersion": 2, | |||
| "requires": true, | |||
| "packages": { | |||
| "": { | |||
| "name": "@threepipe/plugins-extra-importers", | |||
| "version": "0.1.0", | |||
| "version": "0.1.1", | |||
| "license": "Apache-2.0", | |||
| "dependencies": { | |||
| "threepipe": "file:./../../src/" | |||
| @@ -214,7 +214,11 @@ export class MaterialManager<T = ''> extends EventDispatcher<BaseEvent, T> { | |||
| } | |||
| public findMaterialsByName(name: string|RegExp, regex = false): IMaterial[] { | |||
| return this._materials.filter(v=>typeof name !== 'string' || regex ? v.name.match(name) !== null : v.name === name) | |||
| return this._materials.filter(v=> | |||
| typeof name !== 'string' || regex ? | |||
| v.name.match(typeof name === 'string' ? '^' + name + '$' : name) !== null : | |||
| v.name === name | |||
| ) | |||
| } | |||
| public getMaterialsOfType<TM extends IMaterial = IMaterial>(typeSlug: string | undefined): TM[] { | |||
| @@ -294,7 +298,7 @@ export class MaterialManager<T = ''> extends EventDispatcher<BaseEvent, T> { | |||
| applyMaterial(material: IMaterial, nameOrUuid: string): boolean { | |||
| const mType = Object.getPrototypeOf(material).constructor.TYPE | |||
| let currentMats = this.findMaterialsByName(nameOrUuid) | |||
| let currentMats = this.findMaterialsByName(nameOrUuid, true) | |||
| if (!currentMats || currentMats.length < 1) currentMats = [this.findMaterial(nameOrUuid) as any] | |||
| let applied = false | |||
| for (const c of currentMats) { | |||
| @@ -369,6 +369,9 @@ export class RootScene extends Scene<ISceneEvent, ISceneEventTypes> implements I | |||
| return new Box3B().expandByObject(this, precise, ignoreInvisible) | |||
| } | |||
| private _v1 = new Vector3() | |||
| private _v2 = new Vector3() | |||
| /** | |||
| * Refreshes the scene active camera near far values, based on the scene bounding box. | |||
| * This is called automatically every time the camera is updated. | |||
| @@ -381,17 +384,17 @@ export class RootScene extends Scene<ISceneEvent, ISceneEventTypes> implements I | |||
| camera.far = camera.userData.maxFarPlane ?? 1000 | |||
| return | |||
| } | |||
| // todo check if this takes too much time with large scenes(when moving the camera and not animating), but we also need to support animations | |||
| const bbox = this.getBounds(false) // todo: can we use this._sceneBounds or will it have some issue with animation? | |||
| const pos = camera.getWorldPosition(new Vector3()).sub(bbox.getCenter(new Vector3())) | |||
| const radius = 1.5 * bbox.getSize(new Vector3()).length() / 2. | |||
| const dist = pos.length() | |||
| camera.getWorldPosition(this._v1).sub(bbox.getCenter(this._v2)) | |||
| const radius = 1.5 * bbox.getSize(this._v2).length() / 2. | |||
| const dist = this._v1.length() | |||
| // new way | |||
| // todo there is still some clipping when you are inside the model like a room. | |||
| const dist1 = -pos.clone().normalize().dot(camera.getWorldDirection(new Vector3())) | |||
| const dist1 = Math.max(0.1, -this._v1.normalize().dot(camera.getWorldDirection(new Vector3()))) | |||
| const near = Math.max(camera.userData.minNearPlane ?? 0.2, dist1 * (dist - radius)) | |||
| const far = Math.min(Math.max(near + 1, dist1 * (dist + radius)), camera.userData.maxFarPlane ?? 1000) | |||
| const far = Math.min(Math.max(near + radius, dist1 * (dist + radius)), camera.userData.maxFarPlane ?? 1000) | |||
| // old way, has issues when panning very far from the camera target | |||
| // const near = Math.max(camera.userData.minNearPlane ?? 0.2, dist - radius) | |||
| @@ -5,6 +5,7 @@ import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import type {FrameFadePlugin} from '../pipeline/FrameFadePlugin' | |||
| import type {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | |||
| import {generateUUID} from '../../three' | |||
| import {makeSetterFor} from '../../utils' | |||
| export interface AnimationResult{ | |||
| id: string | |||
| @@ -13,6 +14,8 @@ export interface AnimationResult{ | |||
| stop: () => void | |||
| // eslint-disable-next-line @typescript-eslint/naming-convention | |||
| _stop?: () => void | |||
| targetRef?: {target: any, key: string} | |||
| } | |||
| /** | |||
| @@ -20,7 +23,7 @@ export interface AnimationResult{ | |||
| * | |||
| * Provides animation capabilities to the viewer using the popmotion library: https://popmotion.io/ | |||
| * | |||
| * Overrides the driver in popmotion to sync with the viewer and provide ways to store and stop animations. | |||
| * Overrides the driver in popmotion to sync with the viewer and provide ways to keep track and stop animations. | |||
| * | |||
| * @category Plugin | |||
| */ | |||
| @@ -120,15 +123,42 @@ export class PopmotionPlugin extends AViewerPluginSync<''> { | |||
| readonly animations: Record<string, AnimationResult> = {} | |||
| animate<V>(options: AnimationOptions<V>): AnimationResult { | |||
| animateTarget<T>(target: T, key: keyof T, options: AnimationOptions<T[keyof T]>): AnimationResult { | |||
| return this.animate({...options, target, key: key as string}) | |||
| } | |||
| animate<V>(options1: AnimationOptions<V> & {target?: any, key?: string}): AnimationResult { | |||
| let targetRef = undefined | |||
| const options = {...options1} as ((typeof options1) & {lastOnUpdate?: (a:V)=>void}) | |||
| if (options.target !== undefined) { | |||
| if (options.key === undefined) throw new Error('key must be defined') | |||
| if (!(options.key in options.target)) { | |||
| console.warn('key not present in target, creating', options.key, options.target) | |||
| options.target[options.key] = options.from || 0 | |||
| } | |||
| const setter = makeSetterFor(options.target, options.key) | |||
| const fromVal = options.target[options.key] | |||
| options.lastOnUpdate = options.onUpdate | |||
| options.onUpdate = (val: V)=>{ | |||
| setter(val) | |||
| options.lastOnUpdate && options.lastOnUpdate(val) | |||
| } | |||
| targetRef = {target: options.target, key: options.key} | |||
| if (options.from === undefined) options.from = fromVal | |||
| delete options.target | |||
| delete options.key | |||
| } | |||
| const uuid = generateUUID() | |||
| const a: any = { | |||
| const a: AnimationResult = { | |||
| id: uuid, | |||
| options, | |||
| stop: ()=>{ | |||
| if (!this.animations[uuid]?._stop) console.warn('Animation not started') | |||
| else this.animations[uuid]?._stop?.() | |||
| }, | |||
| promise: undefined as any, | |||
| targetRef, | |||
| } | |||
| this.animations[uuid] = a | |||
| a.promise = new Promise<void>((resolve, reject) => { | |||
| @@ -154,6 +184,7 @@ export class PopmotionPlugin extends AViewerPluginSync<''> { | |||
| resolve() | |||
| }, | |||
| } | |||
| // todo: support boolean using timeout. | |||
| const anim = animate(opts) | |||
| this.animations[uuid]._stop = anim.stop | |||
| this.animations[uuid].options = opts | |||
| @@ -165,9 +196,13 @@ export class PopmotionPlugin extends AViewerPluginSync<''> { | |||
| return this.animations[uuid] | |||
| } | |||
| async animateAsync<V>(options: AnimationOptions<V>): Promise<string> { | |||
| async animateAsync<V>(options: AnimationOptions<V>& {target?: any, key?: string}): Promise<string> { | |||
| return this.animate(options).promise | |||
| } | |||
| async animateTargetAsync<T>(target: T, key: keyof T, options: AnimationOptions<T[keyof T]>): Promise<string> { | |||
| return this.animate({...options, target, key: key as string}).promise | |||
| } | |||
| // todo : animateObject/animateTarget | |||
| } | |||
| @@ -0,0 +1,126 @@ | |||
| import { | |||
| animate, | |||
| AnimationOptions, | |||
| anticipate, | |||
| backIn, | |||
| backInOut, | |||
| backOut, | |||
| bounceIn, | |||
| bounceInOut, | |||
| bounceOut, | |||
| circIn, | |||
| circInOut, | |||
| circOut, | |||
| easeIn, | |||
| easeInOut, | |||
| easeOut, | |||
| Easing, | |||
| KeyframeOptions, | |||
| linear, | |||
| } from 'popmotion' | |||
| import {timeout} from 'ts-browser-helpers' | |||
| export {animate} | |||
| export type {AnimationOptions, KeyframeOptions, Easing} | |||
| function easeInOutSine(x: number): number { | |||
| return -(Math.cos(Math.PI * x) - 1) / 2 | |||
| } | |||
| // eslint-disable-next-line @typescript-eslint/naming-convention | |||
| export const EasingFunctions = { | |||
| linear: linear, | |||
| easeIn: easeIn, | |||
| easeOut: easeOut, | |||
| easeInOut: easeInOut, | |||
| circIn: circIn, | |||
| circOut: circOut, | |||
| circInOut: circInOut, | |||
| backIn: backIn, | |||
| backOut: backOut, | |||
| backInOut: backInOut, | |||
| anticipate: anticipate, | |||
| bounceOut: bounceOut, | |||
| bounceIn: bounceIn, | |||
| bounceInOut: bounceInOut, | |||
| easeInOutSine: easeInOutSine, | |||
| } | |||
| /** | |||
| * EasingFunctionType: | |||
| * anticipate, backIn, backInOut, backOut, bounceIn, bounceInOut, bounceOut, circIn, circInOut, circOut, easeIn, easeInOut, easeOut, easeInOutSine | |||
| */ | |||
| export type EasingFunctionType = keyof typeof EasingFunctions | |||
| export type AnimateResult = ReturnType<typeof animate> | |||
| export function makeSetterFor<V>(target: any, key: string, setDirty?: ()=>void) { | |||
| const v = target[key] as any | |||
| const dirty = ()=>{ | |||
| if (typeof target?.setDirty === 'function') target.setDirty() | |||
| setDirty?.() | |||
| } | |||
| if (v && typeof v.copy === 'function') | |||
| return (a: any) => { | |||
| v.copy(a) | |||
| dirty() | |||
| } | |||
| else | |||
| return (a: V)=>{ | |||
| target[key] = a | |||
| dirty() | |||
| } | |||
| } | |||
| export async function animateTarget<V>(target: any, key: string, options: AnimationOptions<V>, animations?: AnimateResult[]) { | |||
| if (!(key in target)) { | |||
| console.error('invalid key', key, target) | |||
| } | |||
| const setter = makeSetterFor(target, key) | |||
| const fromVal = target[key] | |||
| const onUpdate = (val: V)=>{ | |||
| setter(val) | |||
| options.onUpdate && options.onUpdate(val) | |||
| } | |||
| if (typeof fromVal === 'boolean') { | |||
| const {duration} = options as KeyframeOptions // todo: divide by 2? or support keyframes. | |||
| return timeout(duration ?? 0).then(()=>onUpdate(options.to as V)) | |||
| } else { | |||
| if (typeof options.to === 'function') { | |||
| options = {...options, to: options.to(fromVal, target)} // need to duplicate options | |||
| } | |||
| return animateAsync({ | |||
| ...options, | |||
| from: fromVal, | |||
| onUpdate, | |||
| } as AnimationOptions<V>, animations) | |||
| } | |||
| } | |||
| export async function animateAsync<V=number>(options: AnimationOptions<V>, animations?: AnimateResult[]) { | |||
| const complete = options.onComplete | |||
| const stop = options.onStop | |||
| options = {...options} | |||
| return new Promise<void>((resolve, reject) => { | |||
| options.onComplete = ()=>{ | |||
| try { | |||
| complete?.() | |||
| } catch (e: any) { | |||
| reject(e) | |||
| return | |||
| } | |||
| resolve() | |||
| } | |||
| options.onStop = ()=>{ | |||
| try { | |||
| stop?.() | |||
| } catch (e: any) { | |||
| reject(e) | |||
| return | |||
| } | |||
| resolve() | |||
| } | |||
| const an = animate(options) | |||
| if (animations) animations.push(an) | |||
| }) | |||
| } | |||
| @@ -6,4 +6,6 @@ export {Dropzone, type DropFile, type ListenerCallback, type DropEventType} from | |||
| export {ThreeSerialization, type SerializationMetaType, type SerializationResourcesType, MetaImporter, metaToResources, getEmptyMeta, metaFromResources, convertArrayBufferToStringsInMeta, convertStringsToArrayBuffersInMeta, copyMaterialUserData, copyObject3DUserData, copyUserData, copyTextureUserData, jsonToBlob, serializeTextureInExtras} from './serialization' | |||
| export {shaderReplaceString} from './shader-helpers' | |||
| export {makeGLBFile} from './gltf' | |||
| export {animateAsync, animateTarget, EasingFunctions, makeSetterFor, animate} from './animation' | |||
| export type {Easing, KeyframeOptions, AnimationOptions, EasingFunctionType, AnimateResult} from './animation' | |||
| @@ -189,6 +189,9 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| readonly assetManager: AssetManager | |||
| @uiConfig() @serialize('renderManager') | |||
| readonly renderManager: ViewerRenderManager | |||
| get materialManager() { | |||
| return this.assetManager.materials | |||
| } | |||
| public readonly plugins: Record<string, IViewerPlugin> = {} | |||
| /** | |||
| * Scene with object hierarchy used for rendering | |||
| @@ -412,7 +415,7 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| * @param setBackground - Set the background image of the scene from the same map. | |||
| * @param options - Options for importing the asset. See {@link ImportAssetOptions} | |||
| */ | |||
| async setEnvironmentMap(map: string | IAsset | null | ITexture, {setBackground = false, ...options}: ImportAssetOptions&{setBackground?: boolean} = {}): Promise<ITexture | null> { | |||
| async setEnvironmentMap(map: string | IAsset | null | ITexture | undefined, {setBackground = false, ...options}: ImportAssetOptions&{setBackground?: boolean} = {}): Promise<ITexture | null> { | |||
| this._scene.environment = map && !(<ITexture>map).isTexture ? await this.assetManager.importer.importSingle<ITexture>(map as string|IAsset, options) || null : <ITexture>map || null | |||
| if (setBackground) return this.setBackgroundMap(this._scene.environment) | |||
| return this._scene.environment | |||
| @@ -424,7 +427,7 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| * @param setEnvironment - Set the environment map of the scene from the same map. | |||
| * @param options - Options for importing the asset. See {@link ImportAssetOptions} | |||
| */ | |||
| async setBackgroundMap(map: string | IAsset | null | ITexture, {setEnvironment = false, ...options}: ImportAssetOptions&{setBackground?: boolean} = {}): Promise<ITexture | null> { | |||
| async setBackgroundMap(map: string | IAsset | null | ITexture | undefined, {setEnvironment = false, ...options}: ImportAssetOptions&{setBackground?: boolean} = {}): Promise<ITexture | null> { | |||
| this._scene.background = map && !(<ITexture>map).isTexture ? await this.assetManager.importer.importSingle<ITexture>(map as string|IAsset, options) || null : <ITexture>map || null | |||
| if (setEnvironment) return this.setEnvironmentMap(this._scene.background) | |||
| return this._scene.background | |||