diff --git a/src/framework/entity.js b/src/framework/entity.js index e6591ba5d87..f04999866dc 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -1,5 +1,6 @@ import { Debug } from '../core/debug.js'; import { guid } from '../core/guid.js'; +import { BoundingBox } from '../core/shape/bounding-box.js'; import { GraphNode } from '../scene/graph-node.js'; import { getApplication } from './globals.js'; @@ -28,6 +29,14 @@ import { getApplication } from './globals.js'; * @import { ScrollbarComponent } from './components/scrollbar/component.js' * @import { SoundComponent } from './components/sound/component.js' * @import { SpriteComponent } from './components/sprite/component.js' + * @import { MeshInstance } from '../scene/mesh-instance.js' + */ + +/** + * @callback RenderMeshInstanceCallback + * @param {MeshInstance} meshInstance - The render mesh instance. + * @param {RenderComponent|ModelComponent} component - The component that owns the mesh instance. + * @param {Entity} entity - The entity that owns the component. */ /** @@ -466,6 +475,63 @@ class Entity extends GraphNode { return this.find(entity => entity.c?.[type]).map(entity => entity.c[type]); } + /** + * Executes a provided function once for each {@link MeshInstance} on this entity and all of + * its descendants. + * + * @param {RenderMeshInstanceCallback} callback - The function to execute on each mesh + * instance. + * @param {object} [thisArg] - Optional value to use as this when executing callback function. + * @example + * entity.forEachRenderMeshInstance((meshInstance) => { + * meshInstance.visible = false; + * }); + */ + forEachRenderMeshInstance(callback, thisArg) { + this.forEach((entity) => { + const render = entity.c.render; + const renderMeshes = render?.meshInstances; + for (let i = 0, len = renderMeshes?.length ?? 0; i < len; i++) { + callback.call(thisArg, renderMeshes[i], render, entity); + } + + const model = entity.c.model; + const modelMeshes = model?.meshInstances; + for (let i = 0, len = modelMeshes?.length ?? 0; i < len; i++) { + callback.call(thisArg, modelMeshes[i], model, entity); + } + }); + } + + /** + * Gets the axis-aligned bounding box enclosing all render mesh instances on this entity and all + * of its descendants. + * + * @param {BoundingBox} [result] - The bounding box to receive the result. + * @returns {BoundingBox|null} The bounding box, or null if no render mesh instances were found. + * @example + * const aabb = entity.getAabb(); + * if (aabb) { + * // use aabb + * } + */ + getAabb(result) { + let aabb = result; + let found = false; + + this.forEachRenderMeshInstance((meshInstance) => { + if (found) { + aabb.add(meshInstance.aabb); + } else { + aabb = aabb || new BoundingBox(); + aabb.copy(meshInstance.aabb); + found = true; + } + }); + + return found ? aabb : null; + } + /** * Search the entity and all of its descendants for the first script instance of specified type. * diff --git a/test/framework/entity.test.mjs b/test/framework/entity.test.mjs index f747bcec6b8..89ddbf76c3b 100644 --- a/test/framework/entity.test.mjs +++ b/test/framework/entity.test.mjs @@ -3,6 +3,8 @@ import { stub } from 'sinon'; import { DummyComponentSystem } from './test-component/system.mjs'; import { Color } from '../../src/core/math/color.js'; +import { Vec3 } from '../../src/core/math/vec3.js'; +import { BoundingBox } from '../../src/core/shape/bounding-box.js'; import { AnimComponent } from '../../src/framework/components/anim/component.js'; import { AnimationComponent } from '../../src/framework/components/animation/component.js'; import { AudioListenerComponent } from '../../src/framework/components/audio-listener/component.js'; @@ -695,6 +697,73 @@ describe('Entity', function () { }); + describe('#forEachRenderMeshInstance', function () { + + it('visits render and model mesh instances in hierarchy order', function () { + const root = new Entity('root'); + const child = new Entity('child'); + const grandchild = new Entity('grandchild'); + root.addChild(child); + child.addChild(grandchild); + + const rootMesh = { aabb: new BoundingBox() }; + const childMesh = { aabb: new BoundingBox() }; + const grandchildMesh = { aabb: new BoundingBox() }; + const render = { meshInstances: [rootMesh] }; + const model = { meshInstances: [childMesh] }; + const childRender = { meshInstances: [grandchildMesh] }; + root.c.render = render; + child.c.model = model; + grandchild.c.render = childRender; + + const calls = []; + root.forEachRenderMeshInstance((meshInstance, component, entity) => { + calls.push([meshInstance, component, entity]); + }); + + expect(calls).to.deep.equal([ + [rootMesh, render, root], + [childMesh, model, child], + [grandchildMesh, childRender, grandchild] + ]); + }); + + }); + + describe('#getAabb', function () { + + it('returns null when no render mesh instances are found', function () { + const entity = new Entity(); + + expect(entity.getAabb()).to.equal(null); + }); + + it('unions descendant render mesh instance bounds into the result', function () { + const root = new Entity(); + const child = new Entity(); + root.addChild(child); + + root.c.render = { + meshInstances: [{ + aabb: new BoundingBox(new Vec3(0, 0, 0), new Vec3(1, 2, 3)) + }] + }; + child.c.model = { + meshInstances: [{ + aabb: new BoundingBox(new Vec3(5, 0, 0), new Vec3(1, 1, 1)) + }] + }; + + const result = new BoundingBox(); + const aabb = root.getAabb(result); + + expect(aabb).to.equal(result); + expect(aabb.center.equals(new Vec3(2.5, 0, 0))).to.equal(true); + expect(aabb.halfExtents.equals(new Vec3(3.5, 2, 3))).to.equal(true); + }); + + }); + describe('#findScript', function () { it('finds script on single entity', function () {