diff --git a/package-lock.json b/package-lock.json index 04bec8d..1674cce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.0.0", "dependencies": { "@speckle/viewer": "^2.22.1", + "potpack": "2.0.0", "vue": "^3.5.12" }, "devDependencies": { "@tsconfig/node20": "^20.1.4", "@types/node": "^20.17.6", + "@types/three": "^0.169.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/eslint-config-prettier": "^10.1.0", "@vue/eslint-config-typescript": "^14.1.3", @@ -1724,6 +1726,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1779,6 +1787,32 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/stats.js": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", + "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", + "dev": true + }, + "node_modules/@types/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "dev": true, + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.20.tgz", + "integrity": "sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.13.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.13.0.tgz", @@ -2291,6 +2325,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@webgpu/types": { + "version": "0.1.51", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.51.tgz", + "integrity": "sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==", + "dev": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3497,6 +3537,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4365,6 +4411,12 @@ "integrity": "sha512-WKghTBzqAvTt9rG5TWS78Dmk2kCCL9VkkX8Zi9kKfJ4iqYpvcGGpeYtkhPHa9NZAPLivZiZsdO/LBG3ENayDmQ==", "license": "MIT" }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5190,6 +5242,11 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", + "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 68b6006..65ac8d7 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ }, "dependencies": { "@speckle/viewer": "^2.22.1", + "potpack": "2.0.0", "vue": "^3.5.12" }, "devDependencies": { "@tsconfig/node20": "^20.1.4", "@types/node": "^20.17.6", + "@types/three": "^0.169.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/eslint-config-prettier": "^10.1.0", "@vue/eslint-config-typescript": "^14.1.3", diff --git a/src/extensions/AnimationGroup.ts b/src/extensions/AnimationGroup.ts new file mode 100644 index 0000000..7a8ff1b --- /dev/null +++ b/src/extensions/AnimationGroup.ts @@ -0,0 +1,97 @@ +import { BatchObject } from '@speckle/viewer' +import { Vector3 } from 'three' + +export interface Animation { + target: BatchObject + end: Vector3 + current: Vector3 + time: number +} + +const ZERO = 1e-8 +const ONE = 1 - 1e-8 +const vec3: Vector3 = new Vector3() + +const easeOutQuart = (x: number) => { + return 1 - Math.pow(1 - x, 4) +} + +export class AnimationGroup { + /** We'll store our animations here */ + public animations: Animation[] = [] + /** Animation params */ + public animTimeScale: number = 0.25 + private reverse = false + private _isAnimating = false + + public onComplete: (() => void) | null = null + + public get isAnimating(): boolean { + return this._isAnimating + } + + public update(deltaTime: number): number { + if (!this.animations.length || !this._isAnimating) return 0 + + let animCount = 0 + for (let k = 0; k < this.animations.length; k++) { + /** Animation finished, no need to update it */ + if (this.animations[k].time === 1 || this.animations[k].time === 0) { + continue + } + /** Compute the next animation time value */ + const t = + this.animations[k].time + + (this.reverse + ? -(deltaTime * this.animTimeScale) + : deltaTime * this.animTimeScale) + + /** Clamp it to 1 */ + this.animations[k].time = Math.min(Math.max(t, 0), 1) + let easedT = easeOutQuart(this.animations[k].time) + if (this.animations[k].time === 1) easedT = 1 + if (this.animations[k].time === 0) easedT = 0 + + /** Compute current position value based on animation time */ + 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, + ) + animCount++ + } + + if (this._isAnimating && !animCount) { + this._isAnimating = false + if (this.onComplete) this.onComplete() + } + + return animCount + } + + public play() { + for (let k = 0; k < this.animations.length; k++) { + this.animations[k].time = ZERO + } + this.reverse = false + this._isAnimating = true + } + + public playReverse() { + for (let k = 0; k < this.animations.length; k++) { + this.animations[k].time = ONE + } + this.reverse = true + this._isAnimating = true + } + + public clear() { + this.animations = [] + this.reverse = false + this._isAnimating = false + } +} diff --git a/src/extensions/Catalogue.ts b/src/extensions/Catalogue.ts new file mode 100644 index 0000000..d779c72 --- /dev/null +++ b/src/extensions/Catalogue.ts @@ -0,0 +1,227 @@ +import { + BatchObject, + NodeRenderView, + ObjectLayers, + SpeckleText, + SpeckleTextMaterial, + Extension, +} from '@speckle/viewer' +import potpack from 'potpack' +import { Color, Matrix4, Vector3, Box3, DoubleSide, Group, Mesh } from 'three' +import { AnimationGroup } from './AnimationGroup' + +interface Box { + x: number + y: number + w: number + h: number +} + +interface ObjectBox extends Box { + object: BatchObject +} + +interface CategoryBox extends Box { + category: string + boxes: Array +} + +export class Catalogue extends Extension { + private animationGroup: AnimationGroup = new AnimationGroup() + private textGroup: Group = new Group() + + /** We're tying in to the viewer core's frame event */ + public onEarlyUpdate(deltaTime: number) { + const animCount = this.animationGroup.update(deltaTime) + + /** If any animations updated, request a render */ + if (animCount) { + this.viewer.requestRender() + } + } + + public animate(reverse: boolean = false) { + if (reverse) this.animationGroup.playReverse() + else this.animationGroup.play() + /** After the next release we'll do this*/ + // this.viewer.getRenderer().resetPipeline(true) + this.animationGroup.onComplete = () => { + this.viewer.getRenderer().resetPipeline() + } + } + + /** Example's main function */ + public async categorize( + input: Array<{ ids: Array; value: string }>, + annotations = false, + ) { + if (this.animationGroup.animations.length) return + + const padding = 0.5 + const categoryPadding = 10 + const origin = new Vector3(0, 0, 0) + + const objectBoxes: { [id: string]: ObjectBox } = {} + 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]) + if (!nodes) continue + + /** Just get the first node */ + const node = nodes[0] + + const rvs = this.viewer + .getWorldTree() + .getRenderTree() + .getRenderViewsForNode(node) + + const objects: BatchObject[] = rvs + .map((rv: NodeRenderView) => { + return this.viewer.getRenderer().getObject(rv) + }) + .filter((value: BatchObject | null) => { + return value && !objectBoxes[value.renderView.renderData.id] + }) as BatchObject[] + + 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) + }) + + if (!objects.length) continue + + const { w, h } = potpack(boxes) + categories[cat.value] = { + category: cat.value, + boxes, + w: w + categoryPadding, + h: h + categoryPadding, + x: 0, + y: 0, + } + } + potpack(Object.values(categories)) + console.log(categories) + } + + this.makeAnimations(categories, origin) + if (annotations) await this.makeAnnotations(categories, origin) + } + + private makeAnimations( + categories: { [categoryName: string]: CategoryBox }, + origin: Vector3, + ) { + for (const k in categories) { + for (let i = 0; i < categories[k].boxes.length; i++) { + 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), + ) + + 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.animationGroup.animations.push({ + target: bObj, + end: finalPos, + current: new Vector3(), + time: 0, + }) + } + } + } + + private async makeAnnotations( + categories: { [categoryName: string]: CategoryBox }, + origin: Vector3, + ) { + for (const categoryBox in categories) { + /** Create a speckle text object */ + const text = new SpeckleText('test-text', ObjectLayers.OVERLAY) + + /** Simple text material */ + const material = new SpeckleTextMaterial( + { + color: 0x1a1a1a, + opacity: 1, + side: DoubleSide, + }, + ['USE_RTE', 'BILLBOARD_FIXED'], + ) + material.toneMapped = false + material.color.convertSRGBToLinear() + material.opacity = 1 + material.transparent = false + material.depthTest = false + material.billboardPixelHeight = 20 + material.userData.billboardPos.value.copy(text.position) + ;(text.textMesh as unknown as Mesh).material = + material.getDerivedMaterial() + + 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) + /** Update the text with the cateogry name, size and anchor */ + await text + .update({ + textValue: categories[categoryBox].category, + height: 1, + anchorX: '50%', + anchorY: '43%', + }) + .then(() => { + text.style = { + textColor: new Color(0x1a1a1a), + backgroundColor: new Color(0xffffff), + billboard: true, + backgroundPixelHeight: 20, + } + /** Move the text to the bottom center of the category box */ + text.setTransform( + new Vector3( + origin.x + categories[categoryBox].x, + origin.y + categories[categoryBox].y, + 0, + ), + ) + }) + /** Add the text to the scene */ + this.textGroup.add(text) + } + this.viewer.getRenderer().scene.add(this.textGroup) + } + + public wipe() { + this.animationGroup.clear() + this.textGroup.clear() + this.viewer.getRenderer().scene.remove(this.textGroup) + } +}