diff --git a/package-lock.json b/package-lock.json index 875033f..79edc4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 202cc66..28a19f1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "@speckle/viewer": "^2.22.2", "potpack": "2.0.0", + "gsap": "3.12.5", "vue": "^3.5.12" }, "devDependencies": { diff --git a/src/extensions/AnimationGroup.ts b/src/extensions/AnimationGroup.ts index 7a8ff1b..07f1cb9 100644 --- a/src/extensions/AnimationGroup.ts +++ b/src/extensions/AnimationGroup.ts @@ -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 } } diff --git a/src/extensions/Catalogue.ts b/src/extensions/Catalogue.ts index 8f2edfb..66b1eec 100644 --- a/src/extensions/Catalogue.ts +++ b/src/extensions/Catalogue.ts @@ -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; 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 } }