Added GSAP as an alternate animation library. Added a no-animation option too. More simplifying, refining and documenting

This commit is contained in:
AlexandruPopovici
2024-11-11 12:10:31 +02:00
parent c4757a5c22
commit d1bb2a8c22
4 changed files with 276 additions and 103 deletions
+6
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@speckle/viewer": "^2.22.2",
"gsap": "3.12.5",
"potpack": "2.0.0",
"vue": "^3.5.12"
},
@@ -3760,6 +3761,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/gsap": {
"version": "3.12.5",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.12.5.tgz",
"integrity": "sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ=="
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@speckle/viewer": "^2.22.2",
"potpack": "2.0.0",
"gsap": "3.12.5",
"vue": "^3.5.12"
},
"devDependencies": {
+15 -13
View File
@@ -20,16 +20,21 @@ export class AnimationGroup {
/** We'll store our animations here */
public animations: Animation[] = []
/** Animation params */
public animTimeScale: number = 0.25
private reverse = false
public animTimeScale: number = 1
private _isReverse = false
private _isAnimating = false
public onStart: (() => void) | null = null
public onComplete: (() => void) | null = null
public get isAnimating(): boolean {
return this._isAnimating
}
public set animationDuration(value: number) {
this.animTimeScale = 1 / value
}
public update(deltaTime: number): number {
if (!this.animations.length || !this._isAnimating) return 0
@@ -42,7 +47,7 @@ export class AnimationGroup {
/** Compute the next animation time value */
const t =
this.animations[k].time +
(this.reverse
(this._isReverse
? -(deltaTime * this.animTimeScale)
: deltaTime * this.animTimeScale)
@@ -56,12 +61,7 @@ export class AnimationGroup {
vec3.set(0, 0, 0)
const value = vec3.lerp(this.animations[k].end, easedT)
/** Apply the translation */
this.animations[k].target.transformTRS(
value,
undefined,
undefined,
undefined,
)
this.animations[k].target.transformTRS(value)
animCount++
}
@@ -77,21 +77,23 @@ export class AnimationGroup {
for (let k = 0; k < this.animations.length; k++) {
this.animations[k].time = ZERO
}
this.reverse = false
this._isReverse = false
this._isAnimating = true
if (this.onStart) this.onStart()
}
public playReverse() {
public reverse() {
for (let k = 0; k < this.animations.length; k++) {
this.animations[k].time = ONE
}
this.reverse = true
this._isReverse = true
this._isAnimating = true
if (this.onStart) this.onStart()
}
public clear() {
this.animations = []
this.reverse = false
this._isReverse = false
this._isAnimating = false
}
}
+254 -90
View File
@@ -9,8 +9,29 @@ import {
} from '@speckle/viewer'
import potpack from 'potpack'
import { Color, Matrix4, Vector3, Box3, DoubleSide, Group, Mesh } from 'three'
import gsap from 'gsap'
import { AnimationGroup } from './AnimationGroup'
/** Buffers that help us avoid pointless allocations */
const vec3: Vector3 = new Vector3()
const box3: Box3 = new Box3()
const mat4: Matrix4 = new Matrix4()
export interface AnimationData {
target: BatchObject /** The object that will get animated/translated */
end: Vector3 /** The translation end value */
current: Vector3 /** The translation current value */
time: number /** The animation's current time [0,1] */
}
export interface CatalogueOptions {
origin: Vector3 /** The origin where the objects will be centered around */
duration: number /** Duration of the animation (if applicable) */
labels: boolean /** Enable labels */
objectPadding: number /** Padding between objects of the same category in meters */
categoryPadding: number /** Padding between categories in meters */
}
interface Box {
x: number
y: number
@@ -28,151 +49,287 @@ interface CategoryBox extends Box {
}
export class Catalogue extends Extension {
private animationGroup: AnimationGroup = new AnimationGroup()
private textGroup: Group = new Group()
/** GSAP Timeline or our own implementation */
private timeline: gsap.core.Timeline | AnimationGroup | null = null
/** Optiona labels root parent */
private labelGroup: Group = new Group()
/** We're tying in to the viewer core's frame event */
public onEarlyUpdate(deltaTime: number) {
const animCount = this.animationGroup.update(deltaTime)
/** We only need the update callback for our own animation implementation */
if (!this.timeline || !(this.timeline instanceof AnimationGroup)) return
const animCount = this.timeline.update(deltaTime)
/** If any animations updated, request a render */
if (animCount) {
this.viewer.requestRender()
}
}
/** Triggers animation */
public animate(reverse: boolean = false) {
if (!reverse) this.animationGroup.play()
else this.animationGroup.playReverse()
if (this.viewer.getRenderer().pipeline instanceof ProgressivePipeline) {
;(
this.viewer.getRenderer().pipeline as ProgressivePipeline
).onStationaryEnd()
}
this.animationGroup.onComplete = () => {
if (this.viewer.getRenderer().pipeline instanceof ProgressivePipeline) {
;(
this.viewer.getRenderer().pipeline as ProgressivePipeline
).onStationaryBegin()
this.viewer.getRenderer().resetPipeline()
}
if (!this.timeline) return
if (reverse) {
this.enableLabels(false)
this.timeline.reverse()
} else {
this.enableLabels(true)
this.timeline.play()
}
}
/** Example's main function */
/** Categorizes objects by the provided property values */
public async categorize(
input: Array<{ ids: Array<string>; value: string }>,
annotations = false,
options: CatalogueOptions = {
origin: new Vector3(),
duration: 2,
labels: true,
objectPadding: 0.5,
categoryPadding: 10,
},
) {
if (this.animationGroup.animations.length) return
const padding = 0.5
const categoryPadding = 10
const origin = new Vector3(0, 0, 0)
if (this.timeline) return
/** Map of every object's box */
const objectBoxes: { [id: string]: ObjectBox } = {}
/** Map of every category */
const categories: { [categoryName: string]: CategoryBox } = {}
for (const cat of input) {
const boxes: ObjectBox[] = []
for (let k = 0; k < cat.ids.length; k++) {
const nodes = this.viewer.getWorldTree().findId(cat.ids[k])
for (const category of input) {
const categoryBoxes: ObjectBox[] = []
for (let k = 0; k < category.ids.length; k++) {
const nodes = this.viewer.getWorldTree().findId(category.ids[k])
if (!nodes) continue
/** Just get the first node */
const node = nodes[0]
/** Get all render views associated with the node */
const rvs = this.viewer
.getWorldTree()
.getRenderTree()
.getRenderViewsForNode(node)
const objects: BatchObject[] = rvs
/** Get their corresponding batch objects */
rvs
.map((rv: NodeRenderView) => {
return this.viewer.getRenderer().getObject(rv)
})
/** Filter our nulls and duplicates */
.filter((value: BatchObject | null) => {
return value && !objectBoxes[value.renderView.renderData.id]
}) as BatchObject[]
})
/** Create object boxes for each batch object with their size.
* x and y will be later filled out by potpack
*/
.forEach((object: BatchObject | null) => {
if (!object) return
const aabbSize = object.aabb.getSize(vec3)
const box = {
object,
w: aabbSize.x + options.objectPadding,
h: aabbSize.y + options.objectPadding,
x: 0,
y: 0,
} as ObjectBox
objectBoxes[object.renderView.renderData.id] = box
categoryBoxes.push(box)
})
objects.forEach((object: BatchObject) => {
if (!object) return
const aabbSize = object?.aabb.getSize(new Vector3())
const box = {
object,
w: aabbSize.x + padding,
h: aabbSize.y + padding,
x: 0,
y: 0,
}
objectBoxes[object.renderView.renderData.id] = box
boxes.push(box)
})
/** No displayable objects in category */
if (!categoryBoxes.length) continue
if (!objects.length) continue
/** Run bin packing on all object boxes from the category.
* Will compute and fill out x,y for each object box
*/
const { w, h } = potpack(categoryBoxes)
const { w, h } = potpack(boxes)
categories[cat.value] = {
category: cat.value,
boxes,
w: w + categoryPadding,
h: h + categoryPadding,
/** Create category box */
categories[category.value] = {
category: category.value,
boxes: categoryBoxes,
w: w + options.categoryPadding,
h: h + options.categoryPadding,
x: 0,
y: 0,
}
}
potpack(Object.values(categories))
console.log(categories)
}
/** Run bin packing on all categery boxes
* Will compute and fill out x,y for each category box
*/
const { w, h } = potpack(Object.values(categories))
this.makeAnimations(categories, origin)
if (annotations) await this.makeAnnotations(categories, origin)
/** Center around the given origin */
options.origin.sub(new Vector3(w * 0.5, h * 0.5, 0))
/** No animation just move translate objects */
// this.makeNoAnimations(this.makeAnimationData(categories, options))
/** Animate with GSAP */
this.makeAnimationsGSAP(
this.makeAnimationData(categories, options),
options,
)
/** Animate with our own */
// this.makeAnimations(this.makeAnimationData(categories, options), options)
/** Add labels */
if (options.labels) await this.makeLabels(categories, options.origin)
}
private makeAnimations(
private makeAnimationData(
categories: { [categoryName: string]: CategoryBox },
origin: Vector3,
) {
options: CatalogueOptions,
): AnimationData[] {
const animationData = []
/** Assemble the object offsets and category offsets computed by potpack into target translations
* We take extra care to allocate as little as possible since we could be dealing with lots of objects
*/
for (const k in categories) {
for (let i = 0; i < categories[k].boxes.length; i++) {
/** We make a box3 for each object based on updated position values and store it in our box buffer */
const objectBox = categories[k].boxes[i]
const box3 = new Box3(
new Vector3(objectBox.x + origin.x, objectBox.y + origin.y, 0),
new Vector3(
objectBox.x + origin.x + objectBox.w,
objectBox.y + origin.y + objectBox.h,
0,
),
).applyMatrix4(
new Matrix4().makeTranslation(categories[k].x, categories[k].y, 0),
box3.min.set(
objectBox.x + options.origin.x,
objectBox.y + options.origin.y,
0,
)
box3.max.set(
objectBox.x + options.origin.x + objectBox.w,
objectBox.y + options.origin.y + objectBox.h,
0,
)
/** We transform each object with it's category offse */
box3.applyMatrix4(
mat4.makeTranslation(categories[k].x, categories[k].y, 0),
)
const bObj = objectBox.object
const boxCenter = box3.getCenter(new Vector3())
const aabbCenter = bObj.aabb.getCenter(new Vector3())
const aabbSize = bObj.aabb.getSize(new Vector3())
const finalPos = new Vector3()
.copy(boxCenter)
.sub(aabbCenter.sub(new Vector3(0, 0, aabbSize.z * 0.5)))
/** This is our target translation */
const translationValue = new Vector3()
/** We set it to the computed object + category offset */
translationValue.copy(box3.getCenter(vec3))
/** We subtract the object's current offset. Objects do not have a 0 local origin */
translationValue.sub(objectBox.object.aabb.getCenter(vec3))
/** We align all objects to the XY plane */
const localSize = objectBox.object.aabb.getSize(vec3)
localSize.set(0, 0, localSize.z * 0.5)
translationValue.add(localSize)
this.animationGroup.animations.push({
target: bObj,
end: finalPos,
const data = {
target: objectBox.object,
end: translationValue,
current: new Vector3(),
time: 0,
})
} as AnimationData
animationData.push(data)
}
}
return animationData
}
/** No animation, just set object their target translation */
private makeNoAnimations(animationData: AnimationData[]) {
for (const data of animationData) {
data.target.transformTRS(data.end)
}
this.viewer.getRenderer().resetPipeline()
}
/** Our own simple animation implementation */
private makeAnimations(
animationData: AnimationData[],
options: CatalogueOptions,
) {
this.timeline = new AnimationGroup()
this.timeline.animationDuration = options.duration
this.timeline.onStart = () => {
this.animationStart()
}
this.timeline.onComplete = () => {
this.animationEnd()
}
for (const data of animationData) {
this.timeline?.animations.push(data)
}
}
private async makeAnnotations(
/** GSAP backed animation */
private makeAnimationsGSAP(
animationData: AnimationData[],
options: CatalogueOptions,
) {
/** We use a Timeline to group and control all object animations */
this.timeline = new gsap.core.Timeline({
onStart: () => {
this.animationStart()
},
onUpdate: () => {
this.viewer.requestRender()
},
onComplete: () => {
this.animationEnd()
},
onReverseComplete: () => {
this.animationEnd()
},
})
for (const data of animationData) {
/** Create a tween with GSAP's basics */
this.timeline.to(
data.current,
{
x: data.end.x,
y: data.end.y,
z: data.end.z,
duration: options.duration,
/** We give each tween control over applying the transform. It's a bit wasteful but maybe more clear */
onUpdate: () => {
data.target.transformTRS(data.current)
},
},
0,
)
this.timeline.pause()
}
}
/** Rendering pipeline needs to be "woken up" when an animation starts */
private animationStart() {
if (this.viewer.getRenderer().pipeline instanceof ProgressivePipeline) {
;(
this.viewer.getRenderer().pipeline as ProgressivePipeline
).onStationaryEnd()
}
}
/** Rendering pipeline needs to be "put to sleep" when animation ends */
private animationEnd() {
if (this.viewer.getRenderer().pipeline instanceof ProgressivePipeline) {
;(
this.viewer.getRenderer().pipeline as ProgressivePipeline
).onStationaryBegin()
this.viewer.getRenderer().resetPipeline()
}
}
/** Optional labels for each category */
private async makeLabels(
categories: { [categoryName: string]: CategoryBox },
origin: Vector3,
) {
for (const categoryBox in categories) {
/** Create a speckle text object */
const text = new SpeckleText('test-text', ObjectLayers.OVERLAY)
const text = new SpeckleText(
categories[categoryBox].category,
ObjectLayers.OVERLAY,
)
/** Simple text material */
const material = new SpeckleTextMaterial(
@@ -193,12 +350,12 @@ export class Catalogue extends Extension {
;(text.textMesh as unknown as Mesh).material =
material.getDerivedMaterial()
if (text.backgroundMesh) text.backgroundMesh.renderOrder = 3
;(text.textMesh as unknown as Mesh).renderOrder = 4
// if (text.backgroundMesh) text.backgroundMesh.renderOrder = 3
// ;(text.textMesh as unknown as Mesh).renderOrder = 4
/** Set the layers to PROPS, so that AO and interactions will ignore them */
text.layers.set(ObjectLayers.OVERLAY)
;(text.textMesh as unknown as Mesh).layers.set(ObjectLayers.OVERLAY)
// ;(text.textMesh as unknown as Mesh).layers.set(ObjectLayers.OVERLAY)
/** Update the text with the cateogry name, size and anchor */
await text
.update({
@@ -214,7 +371,7 @@ export class Catalogue extends Extension {
billboard: true,
backgroundPixelHeight: 20,
}
/** Move the text to the bottom center of the category box */
/** Move the text to the bottom left of the category box */
text.setTransform(
new Vector3(
origin.x + categories[categoryBox].x,
@@ -223,15 +380,22 @@ export class Catalogue extends Extension {
),
)
})
/** Add the text to the scene */
this.textGroup.add(text)
/** Add the text to the group */
this.labelGroup.add(text)
}
this.viewer.getRenderer().scene.add(this.textGroup)
/** Add the text to the scene */
this.viewer.getRenderer().scene.add(this.labelGroup)
}
public enableLabels(value: boolean) {
this.labelGroup.visible = value
}
public wipe() {
this.animationGroup.clear()
this.textGroup.clear()
this.viewer.getRenderer().scene.remove(this.textGroup)
this.labelGroup.clear()
this.viewer.getRenderer().scene.remove(this.labelGroup)
this.timeline?.clear()
this.timeline = null
}
}