diff --git a/packages/dev/core/src/Cameras/arcRotateCamera.ts b/packages/dev/core/src/Cameras/arcRotateCamera.ts index 8cfe5d16494..5d360f4d455 100644 --- a/packages/dev/core/src/Cameras/arcRotateCamera.ts +++ b/packages/dev/core/src/Cameras/arcRotateCamera.ts @@ -27,6 +27,35 @@ Node.AddNodeConstructor("ArcRotateCamera", (name, scene) => { return () => new ArcRotateCamera(name, 0, 0, 1.0, Vector3.Zero(), scene); }); +/** + * Computes the alpha angle based on the source position and the target position. + * @param offset The directional offset between the source position and the target position + * @returns The alpha angle in radians + */ +export function ComputeAlpha(offset: Vector3): number { + // Default alpha to π/2 to handle the edge case where x and z are both zero (when looking along up axis) + let alpha = Math.PI / 2; + if (!(offset.x === 0 && offset.z === 0)) { + alpha = Math.acos(offset.x / Math.sqrt(Math.pow(offset.x, 2) + Math.pow(offset.z, 2))); + } + + if (offset.z < 0) { + alpha = 2 * Math.PI - alpha; + } + + return alpha; +} + +/** + * Computes the beta angle based on the source position and the target position. + * @param verticalOffset The y value of the directional offset between the source position and the target position + * @param radius The distance between the source position and the target position + * @returns The beta angle in radians + */ +export function ComputeBeta(verticalOffset: number, radius: number): number { + return Math.acos(verticalOffset / radius); +} + /** * This represents an orbital type of camera. * @@ -1148,26 +1177,16 @@ export class ArcRotateCamera extends TargetCamera { this.radius = 0.0001; // Just to avoid division by zero } - // Alpha + // Alpha and Beta const previousAlpha = this.alpha; - if (this._computationVector.x === 0 && this._computationVector.z === 0) { - this.alpha = Math.PI / 2; // avoid division by zero when looking along up axis, and set to acos(0) - } else { - this.alpha = Math.acos(this._computationVector.x / Math.sqrt(Math.pow(this._computationVector.x, 2) + Math.pow(this._computationVector.z, 2))); - } - - if (this._computationVector.z < 0) { - this.alpha = 2 * Math.PI - this.alpha; - } + this.alpha = ComputeAlpha(this._computationVector); + this.beta = ComputeBeta(this._computationVector.y, this.radius); // Calculate the number of revolutions between the new and old alpha values. const alphaCorrectionTurns = Math.round((previousAlpha - this.alpha) / (2.0 * Math.PI)); // Adjust alpha so that its numerical representation is the closest one to the old value. this.alpha += alphaCorrectionTurns * 2.0 * Math.PI; - // Beta - this.beta = Math.acos(this._computationVector.y / this.radius); - this._checkLimits(); } diff --git a/packages/tools/viewer/src/viewer.ts b/packages/tools/viewer/src/viewer.ts index 84ffc1ff0b8..4e883a04b89 100644 --- a/packages/tools/viewer/src/viewer.ts +++ b/packages/tools/viewer/src/viewer.ts @@ -127,6 +127,32 @@ function updateSkybox(skybox: Nullable, camera: Camera): void { skybox?.scaling.setAll((camera.maxZ - camera.minZ) / 2); } +/** + * Updates the bounding info for the model by computing its maximum extents, size, and center considering animation, skeleton, and morph targets. + * @param assetContainer The asset container representing the model + * @param animationGroup The animation group to consider when computing the bounding info + * @returns The computed bounding info for the model or null if no meshes are present in the asset container + */ +function computeBoundingInfos(assetContainer: AssetContainer, animationGroup: Nullable = null): Nullable { + if (assetContainer.meshes.length) { + const maxExtents = computeMaxExtents(assetContainer.meshes, animationGroup); + const min = new Vector3(Math.min(...maxExtents.map((e) => e.minimum.x)), Math.min(...maxExtents.map((e) => e.minimum.y)), Math.min(...maxExtents.map((e) => e.minimum.z))); + const max = new Vector3(Math.max(...maxExtents.map((e) => e.maximum.x)), Math.max(...maxExtents.map((e) => e.maximum.y)), Math.max(...maxExtents.map((e) => e.maximum.z))); + const size = max.subtract(min); + const center = min.add(size.scale(0.5)); + + return { + extents: { + min: min.asArray(), + max: max.asArray(), + }, + size: size.asArray(), + center: center.asArray(), + }; + } + return null; +} + export type ViewerDetails = { /** * Provides access to the Scene managed by the Viewer. @@ -141,7 +167,7 @@ export type ViewerDetails = { /** * Provides access to the currently loaded model. */ - model: Nullable; + model: Nullable; /** * Suspends the render loop. @@ -236,6 +262,15 @@ export class ViewerHotSpotResult { public visibility: number = NaN; } +export type ViewerBoundingInfo = { + extents: Readonly<{ + readonly min: readonly [x: number, y: number, z: number]; + readonly max: readonly [x: number, y: number, z: number]; + }>; + readonly size: readonly [x: number, y: number, z: number]; + readonly center: readonly [x: number, y: number, z: number]; +}; + export type Model = IDisposable & Readonly<{ /** @@ -252,6 +287,20 @@ export type Model = IDisposable & * Returns the world position and visibility of a hot spot. */ getHotSpotToRef(query: Readonly, result: ViewerHotSpotResult): boolean; + + /** + * Compute and return the world bounds of the model. + * The minimum and maximum extents, the size and the center. + * @param animationIndex The index of the animation group to consider when computing the bounding info. + * @returns The computed bounding info for the model or null if no meshes are present in the asset container. + */ + getWorldBounds(animationIndex: number): Nullable; + + /** + * Resets the computed world bounds of the model. + * Should be called after the model undergoes transformations. + */ + resetWorldBounds(): void; }>; /** @@ -342,6 +391,7 @@ export class Viewer implements IDisposable { private readonly _imageProcessingConfigurationObserver: Observer; private _renderLoopController: Nullable = null; private _modelInfo: Nullable = null; + private _computedWorldBounds: ViewerBoundingInfo[] = []; private _skybox: Nullable = null; private _skyboxBlur: number = 0.3; private _light: Nullable = null; @@ -454,7 +504,7 @@ export class Viewer implements IDisposable { scene: viewer._scene, camera: viewer._camera, get model() { - return viewer._modelInfo?.assetContainer ?? null; + return viewer._modelInfo ?? null; }, suspendRendering: () => this._suspendRendering(), pick: (screenX: number, screenY: number) => this._pick(screenX, screenY), @@ -847,6 +897,20 @@ export class Viewer implements IDisposable { assetContainer.meshes.forEach((mesh) => this._meshDataCache.delete(mesh)); assetContainer.dispose(); this._snapshotHelper.enableSnapshotRendering(); + this._computedWorldBounds = []; + }, + getWorldBounds: (animationIndex: number): Nullable => { + if (!this._computedWorldBounds[animationIndex]) { + const computedBound = computeBoundingInfos(assetContainer, assetContainer.animationGroups[animationIndex]); + if (computedBound) { + this._computedWorldBounds[animationIndex] = computedBound; + return computedBound; + } + } + return this._computedWorldBounds[animationIndex] ?? null; + }, + resetWorldBounds: () => { + this._computedWorldBounds = []; }, }; } catch (e) { @@ -1026,6 +1090,7 @@ export class Viewer implements IDisposable { this._renderLoopController?.dispose(); this._scene.dispose(); + this._modelInfo?.dispose(); this.onEnvironmentChanged.clear(); this.onEnvironmentError.clear(); @@ -1046,7 +1111,7 @@ export class Viewer implements IDisposable { } /** - * retrun world and canvas coordinates of an hot spot + * Return world and canvas coordinates of an hot spot * @param query mesh index and surface information to query the hot spot positions * @param result Query a Hot Spot and does the conversion for Babylon Hot spot to a more generic HotSpotPositions, without Vector types * @returns true if hotspot found @@ -1167,28 +1232,22 @@ export class Viewer implements IDisposable { let goalRadius = 1; let goalTarget = currentTarget; - if (this._modelInfo?.assetContainer.meshes.length) { + const selectedAnimation = this._selectedAnimation === -1 ? 0 : this._selectedAnimation; + const worldBounds = this._modelInfo?.getWorldBounds(selectedAnimation); + if (worldBounds) { // get bounds and prepare framing/camera radius from its values this._camera.lowerRadiusLimit = null; - const maxExtents = computeMaxExtents(this._modelInfo.assetContainer.meshes, this._activeAnimation); - const worldExtents = { - min: new Vector3(Math.min(...maxExtents.map((e) => e.minimum.x)), Math.min(...maxExtents.map((e) => e.minimum.y)), Math.min(...maxExtents.map((e) => e.minimum.z))), - max: new Vector3(Math.max(...maxExtents.map((e) => e.maximum.x)), Math.max(...maxExtents.map((e) => e.maximum.y)), Math.max(...maxExtents.map((e) => e.maximum.z))), - }; - framingBehavior.zoomOnBoundingInfo(worldExtents.min, worldExtents.max); - - const worldSize = worldExtents.max.subtract(worldExtents.min); - const worldCenter = worldExtents.min.add(worldSize.scale(0.5)); - - goalRadius = worldSize.length() * 1.1; + const worldExtentsMin = Vector3.FromArray(worldBounds.extents.min); + const worldExtentsMax = Vector3.FromArray(worldBounds.extents.max); + framingBehavior.zoomOnBoundingInfo(worldExtentsMin, worldExtentsMax); + goalRadius = Vector3.FromArray(worldBounds.size).length() * 1.1; + goalTarget = Vector3.FromArray(worldBounds.center); if (!isFinite(goalRadius)) { goalRadius = 1; - worldCenter.copyFromFloats(0, 0, 0); + goalTarget.copyFromFloats(0, 0, 0); } - - goalTarget = worldCenter; } this._camera.alpha = Math.PI / 2; this._camera.beta = Math.PI / 2.4; diff --git a/packages/tools/viewer/src/viewerElement.ts b/packages/tools/viewer/src/viewerElement.ts index 796709dbcab..256c18a602d 100644 --- a/packages/tools/viewer/src/viewerElement.ts +++ b/packages/tools/viewer/src/viewerElement.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line import/no-internal-modules -import type { ArcRotateCamera, Nullable, Observable } from "core/index"; +import type { Camera, MeshPredicate, Nullable, Observable } from "core/index"; +import { ArcRotateCamera, ComputeAlpha, ComputeBeta } from "core/Cameras/arcRotateCamera"; +import { BuildTuple } from "core/Misc/arrayTools"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { EnvironmentOptions, ToneMapping, ViewerDetails, ViewerHotSpotQuery } from "./viewer"; @@ -79,6 +81,7 @@ export interface ViewerElementEventMap extends HTMLElementEventMap { export abstract class ViewerElement extends LitElement { private readonly _viewerLock = new AsyncLock(); private _viewerDetails?: Readonly; + private readonly _tempVectors = BuildTuple(1, Vector3.Zero); protected constructor(private readonly _viewerClass: new (...args: ConstructorParameters) => ViewerClass) { super(); @@ -696,7 +699,7 @@ export abstract class ViewerElement extends return JSON.parse(value); }, }) - public hotSpots: Readonly> = {}; + public hotSpots: Record = {}; private get _hasHotSpots(): boolean { return Object.keys(this.hotSpots).length > 0; @@ -763,11 +766,17 @@ export abstract class ViewerElement extends @property({ attribute: "material-variant" }) public selectedMaterialVariant: Nullable = null; + /** + * True if scene cameras should be used as hotspots. + */ + @property({ attribute: "cameras-as-hotspots", reflect: true, type: Boolean }) + public camerasAsHotSpots = false; + @query("#canvasContainer") private _canvasContainer: HTMLDivElement | undefined; - @query("#materialSelect") - private _materialSelect: HTMLSelectElement | undefined; + @query("#hotSpotSelect") + private _hotSpotSelect: HTMLSelectElement | undefined; /** * Toggles the play/pause animation state if there is a selected animation. @@ -800,8 +809,8 @@ export abstract class ViewerElement extends protected override update(changedProperties: PropertyValues): void { super.update(changedProperties); - if (this._materialSelect) { - this._materialSelect.value = ""; + if (this._hotSpotSelect) { + this._hotSpotSelect.value = ""; } if (changedProperties.get("engine")) { @@ -821,6 +830,18 @@ export abstract class ViewerElement extends }); } } + + if (changedProperties.has("camerasAsHotSpots")) { + if (this.camerasAsHotSpots) { + this.viewerDetails?.scene.cameras.forEach((camera) => { + this._addCameraHotSpot(camera); + }); + } else { + this.viewerDetails?.scene.cameras.forEach((camera) => { + this._removeCameraHotSpot(camera.name); + }); + } + } } /** @internal */ @@ -905,7 +926,7 @@ export abstract class ViewerElement extends if (this._hasHotSpots) { toolbarControls.push(html`
- ${Object.keys(this.hotSpots).map((name) => html``)} @@ -1049,6 +1070,24 @@ export abstract class ViewerElement extends }; } + private _addCameraHotSpot(camera: Camera) { + if (camera !== this.viewerDetails?.camera) { + this._cameraToHotSpot(camera).then((hotSpot) => { + if (hotSpot) { + this.hotSpots = { + ...this.hotSpots, + [`camera-${camera.name}`]: hotSpot, + }; + } + }); + } + } + + private _removeCameraHotSpot(name: string) { + delete this.hotSpots[`camera-${name}`]; + this.hotSpots = { ...this.hotSpots }; + } + private async _setupViewer() { await this._viewerLock.lockAsync(async () => { // The first time the element is connected, the canvas container may not be available yet. @@ -1125,6 +1164,16 @@ export abstract class ViewerElement extends this._dispatchCustomEvent("animationprogresschange", (type) => new Event(type)); }); + details.scene.onNewCameraAddedObservable.add((camera) => { + if (this.camerasAsHotSpots) { + this._addCameraHotSpot(camera); + } + }); + + details.scene.onCameraRemovedObservable.add((camera) => { + this._removeCameraHotSpot(camera.name); + }); + details.scene.onAfterRenderCameraObservable.add(() => { this._dispatchCustomEvent("viewerrender", (type) => new Event(type)); }); @@ -1203,6 +1252,67 @@ export abstract class ViewerElement extends } } } + + /** + * Calculates the alpha, beta, and radius along with the target point to create a HotSpot from a camera. + * The target point is determined based on the camera's forward ray: + * - If an intersection with the main model is found, the first hit point is used as the target. + * - If no intersection is detected, a fallback target is calculated by projecting + * the distance between the camera and the main model's center along the forward ray. + * + * @param camera The reference camera used to computes alpha, beta, radius and target + * @param predicate Used to define predicate for selecting meshes and instances (if exist) + * @returns A HotSpot, or null if no model found + */ + private async _cameraToHotSpot(camera: Camera, predicate?: MeshPredicate): Promise> { + if (camera instanceof ArcRotateCamera) { + const position = camera.target.clone().asArray(); + return { type: "world", position, normal: position, cameraOrbit: [camera.alpha, camera.beta, camera.radius] }; + } + + await import("core/Culling/ray"); + const ray = camera.getForwardRay(100, camera.getWorldMatrix(), camera.globalPosition); // Set starting point to camera global position + const camGlobalPos = camera.globalPosition.clone(); + + if (this.viewerDetails && this.viewerDetails.model) { + const scene = this.viewerDetails.scene; + const model = this.viewerDetails.model; + + const selectedAnimation = this.selectedAnimation ?? 0; + let worldBounds = model.getWorldBounds(selectedAnimation); + if (worldBounds) { + // Target + let radius: number = 0.0001; // Just to avoid division by zero + const targetPoint = Vector3.Zero(); + const pickingInfo = scene.pickWithRay(ray, predicate); + if (pickingInfo && pickingInfo.hit) { + targetPoint.copyFrom(pickingInfo.pickedPoint!); + } else { + const direction = ray.direction.clone(); + targetPoint.copyFrom(camGlobalPos); + radius = Vector3.Distance(camGlobalPos, Vector3.FromArray(worldBounds.center)); + direction.scaleAndAddToRef(radius, targetPoint); + } + + const computationVector = this._tempVectors[0]; + camGlobalPos.subtractToRef(targetPoint, computationVector); + + // Radius + if (pickingInfo && pickingInfo.hit) { + radius = computationVector.length(); + } + + // Alpha and Beta + const alpha = ComputeAlpha(computationVector); + const beta = ComputeBeta(computationVector.y, radius); + + const hotSpotPosition = targetPoint.asArray(); + return { type: "world", position: hotSpotPosition, normal: hotSpotPosition, cameraOrbit: [alpha, beta, radius] }; + } + } + + return null; + } } /**