Added GSAP as an alternate animation library. Added a no-animation option too. More simplifying, refining and documenting
This commit is contained in:
Generated
+6
@@ -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",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@speckle/viewer": "^2.22.2",
|
||||
"potpack": "2.0.0",
|
||||
"gsap": "3.12.5",
|
||||
"vue": "^3.5.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user