UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

706 lines (604 loc) 32.3 kB
import { NEEDLE_progressive } from "@needle-tools/gltf-progressive"; import { Euler, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "three"; import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js"; import { hasProLicense } from "../../../engine/engine_license.js"; import { serializable } from "../../../engine/engine_serialization.js"; import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js"; import { DeviceUtilities, getParam } from "../../../engine/engine_utils.js"; import { WebXRButtonFactory } from "../../../engine/webcomponents/WebXRButtons.js"; import { InternalUSDZRegistry } from "../../../engine/xr/usdz.js" import { InstancingHandler } from "../../../engine-components/RendererInstancing.js"; import { Collider } from "../../Collider.js"; import { Behaviour, GameObject } from "../../Component.js"; import { ContactShadows } from "../../ContactShadows.js"; import { GroundProjectedEnv } from "../../GroundProjection.js"; import { Renderer } from "../../Renderer.js" import { Rigidbody } from "../../RigidBody.js"; import { SpriteRenderer } from "../../SpriteRenderer.js"; import { WebARSessionRoot } from "../../webxr/WebARSessionRoot.js"; import { WebXR } from "../../webxr/WebXR.js"; import { XRState, XRStateFlag } from "../../webxr/XRFlag.js"; import type { IUSDExporterExtension } from "./Extension.js"; import { AnimationExtension } from "./extensions/Animation.js" import { AudioExtension } from "./extensions/behavior/AudioExtension.js"; import { BehaviorExtension } from "./extensions/behavior/Behaviour.js"; import { PhysicsExtension } from "./extensions/behavior/PhysicsExtension.js" import { TextExtension } from "./extensions/USDZText.js"; import { USDZUIExtension } from "./extensions/USDZUI.js"; import { USDZExporter as ThreeUSDZExporter } from "./ThreeUSDZExporter.js"; import { disableObjectsAtStart, registerAnimatorsImplictly, registerAudioSourcesImplictly } from "./utils/animationutils.js"; import { ensureQuicklookLinkIsCreated } from "./utils/quicklook.js"; const debug = getParam("debugusdz"); const debugUsdzPruning = getParam("debugusdzpruning"); /** * Custom branding for the QuickLook overlay, used by {@link USDZExporter}. */ export class CustomBranding { /** The call to action button text. If not set, the button will close the QuickLook overlay. */ @serializable() callToAction?: string; /** The title of the overlay. */ @serializable() checkoutTitle?: string; /** The subtitle of the overlay. */ @serializable() checkoutSubtitle?: string; /** if assigned the call to action button in quicklook will open the URL. Otherwise it will just close quicklook. */ @serializable() callToActionURL?: string; } /** * Exports the current scene or a specific object as USDZ file and opens it in QuickLook on iOS/iPadOS/visionOS. * The USDZ file is generated using the Needle Engine ThreeUSDZExporter. * The exporter supports various extensions to add custom behaviors and interactions to the USDZ file. * The exporter can automatically collect Animations and AudioSources and export them as playing at the start. * The exporter can also add a custom QuickLook overlay with a call to action button and custom branding. * @example * ```typescript * const usdz = new USDZExporter(); * usdz.objectToExport = myObject; * usdz.autoExportAnimations = true; * usdz.autoExportAudioSources = true; * usdz.exportAsync(); * ``` * @category XR * @group Components */ export class USDZExporter extends Behaviour { /** * Assign the object to export as USDZ file. If undefined or null, the whole scene will be exported. */ @serializable(Object3D) objectToExport: Object3D | null | undefined = undefined; /** Collect all Animations/Animators automatically on export and emit them as playing at the start. * Animator state chains and loops will automatically be collected and exported in order as well. * If this setting is off, Animators need to be registered by components – for example from PlayAnimationOnClick. */ @serializable() autoExportAnimations: boolean = true; /** Collect all AudioSources automatically on export and emit them as playing at the start. * They will loop according to their settings. * If this setting is off, Audio Sources need to be registered by components – for example from PlayAudioOnClick. */ @serializable() autoExportAudioSources: boolean = true; @serializable() exportFileName: string | null | undefined = undefined; @serializable(URL) customUsdzFile: string | null | undefined = undefined; @serializable(CustomBranding) customBranding?: CustomBranding; // Currently not exposed to integrations - not fully tested. Set from code (e.g. image tracking) @serializable() anchoringType: "plane" | "image" | "face" | "none" = "plane"; @serializable() maxTextureSize: 256 | 512 | 1024 | 2048 | 4096 | 8192 = 2048; // Currently not exposed to integrations - not fully tested. Set from code (e.g. image tracking) @serializable() planeAnchoringAlignment: "horizontal" | "vertical" | "any" = "horizontal"; /** Enabling this option will export QuickLook-specific preliminary behaviours along with the USDZ files. * These extensions are only supported on QuickLook on iOS/visionOS/MacOS. * Keep this option off for general USDZ usage. */ @serializable() interactive: boolean = true; /** Enabling this option will export the USDZ file with RealityKit physics components. * Rigidbody and Collider components will be converted to their RealityKit counterparts. * Physics are supported on QuickLook in iOS 18+ and VisionOS 1+. * Physics export is automatically turned off when there are no Rigidbody components anywhere on the exported object. */ @serializable() physics: boolean = true; @serializable() allowCreateQuicklookButton: boolean = true; @serializable() quickLookCompatible: boolean = true; /** * Extensions to add custom behaviors and interactions to the USDZ file. * You can add your own extensions here by extending {@link IUSDExporterExtension}. */ extensions: IUSDExporterExtension[] = []; private link!: HTMLAnchorElement; private button?: HTMLButtonElement; /** @internal */ start() { if (debug) { console.log("USDZExporter", this); console.log("Debug USDZ Mode. Press 'T' to export") window.addEventListener("keydown", (evt) => { switch (evt.key) { case "t": this.exportAndOpen(); break; } }); } // fall back to this object or to the scene if it's empty and doesn't have a mesh if (!this.objectToExport) this.objectToExport = this.gameObject; if (!this.objectToExport?.children?.length && !(this.objectToExport as Mesh)?.isMesh) this.objectToExport = this.context.scene; } /** @internal */ onEnable() { const supportsQuickLook = DeviceUtilities.supportsQuickLookAR(); const ios = DeviceUtilities.isiOS() || DeviceUtilities.isiPad(); if (!this.button && (debug || supportsQuickLook || ios)) { if (this.allowCreateQuicklookButton) this.button = this.createQuicklookButton(); this.lastCallback = this.quicklookCallback.bind(this); this.link = ensureQuicklookLinkIsCreated(this.context, supportsQuickLook); this.link.addEventListener('message', this.lastCallback); } if (debug) showBalloonMessage("USDZ Exporter enabled: " + this.name); document.getElementById("open-in-ar")?.addEventListener("click", this.onClickedOpenInARElement); InternalUSDZRegistry.registerExporter(this); } /** @internal */ onDisable() { this.button?.remove(); this.link?.removeEventListener('message', this.lastCallback); if (debug) showBalloonMessage("USDZ Exporter disabled: " + this.name); document.getElementById("open-in-ar")?.removeEventListener("click", this.onClickedOpenInARElement); InternalUSDZRegistry.unregisterExporter(this); } private onClickedOpenInARElement = (evt) => { evt.preventDefault(); this.exportAndOpen(); } /** * Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook. * Use the various public properties of USDZExporter to customize export behaviour. * @deprecated use {@link exportAndOpen} instead */ async exportAsync() { return this.exportAndOpen(); } /** * Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook. * @returns a Promise<Blob> containing the USDZ file */ async exportAndOpen() : Promise<Blob | null> { let name = this.exportFileName ?? this.objectToExport?.name ?? this.name; name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file if (!hasProLicense()) { if (name !== "") name += "-"; name += "MadeWithNeedle"; } if (!this.link) this.link = ensureQuicklookLinkIsCreated(this.context, DeviceUtilities.supportsQuickLookAR()); // ability to specify a custom USDZ file to be used instead of a dynamic one if (this.customUsdzFile) { if (debug) console.log("Exporting custom usdz", this.customUsdzFile) this.openInQuickLook(this.customUsdzFile, name); return null; } if (!this.objectToExport) { console.warn("No object to export", this); return null; } const blob = await this.export(this.objectToExport); if (!blob) { console.error("USDZ generation failed. Please report a bug", this); return null; } if (debug) console.log("USDZ generation done. Downloading as " + name); // TODO Potentially we have to detect QuickLook availability here, // and download the file instead. But browsers keep changing how they deal with non-user-initiated downloads... // https://webkit.org/blog/8421/viewing-augmented-reality-assets-in-safari-for-ios/#:~:text=inside%20the%20anchor.-,Feature%20Detection,-To%20detect%20support /* if (!DeviceUtilities.supportsQuickLookAR()) this.download(blob, name); else */ this.openInQuickLook(blob, name); return blob; } /** * Creates an USDZ file from the current scene or assigned objectToExport and opens it in QuickLook. * @returns a Promise<Blob> containing the USDZ file */ async export(objectToExport: Object3D | undefined): Promise<Blob | null> { // make sure we have an object to export if (!objectToExport) { console.warn("No object to export"); return null; } // if we are already exporting, wait for the current export to finish const taskForThisObject = this._currentExportTasks.get(objectToExport); if (taskForThisObject) { return taskForThisObject; } // start the export const task = this.internalExport(objectToExport); // store the task if (task instanceof Promise) { this._currentExportTasks.set(objectToExport, task); return task.then((blob) => { this._currentExportTasks.delete(objectToExport); return blob; }).catch((e) => { this._currentExportTasks.delete(objectToExport); console.error("Error during USDZ export – please report a bug!", e); return null; }); } return task; } private readonly _currentExportTasks = new Map<Object3D, Promise<Blob | null>>(); private _previousTimeScale: number = 1; private async internalExport(objectToExport: Object3D): Promise<Blob | null> { Progress.start("export-usdz", { onProgress: (progress) => { this.dispatchEvent(new CustomEvent("export-progress", { detail: { progress } })); } }); Progress.report("export-usdz", { message: "Starting export", totalSteps: 40, currentStep: 0 }); Progress.report("export-usdz", { message: "Load progressive textures", autoStep: 5 }); Progress.start("export-usdz-textures", "export-usdz"); // force Sprites to be created const sprites = GameObject.getComponentsInChildren(objectToExport, SpriteRenderer); for (const sprite of sprites) { if (sprite && sprite.enabled) { sprite.updateSprite(true); // force create } } // trigger progressive textures to be loaded: const renderers = GameObject.getComponentsInChildren(objectToExport, Renderer); const progressiveLoading = new Array<Promise<any>>(); let progressiveTasks = 0; // TODO: it would be better to directly integrate this into the exporter and *on export* request the correct LOD level for textures and meshes instead of relying on the renderer etc for (const rend of renderers) { for (const mesh of rend.sharedMeshes) { if (mesh) { const task = NEEDLE_progressive.assignMeshLOD(mesh, 0); if (task instanceof Promise) progressiveLoading.push(new Promise<void>((resolve, reject) => { task.then(() => { progressiveTasks++; Progress.report("export-usdz-textures", { message: "Loaded progressive mesh", currentStep: progressiveTasks, totalSteps: progressiveLoading.length }); resolve(); }).catch((err) => reject(err)); })); } } for (const mat of rend.sharedMaterials) { if (mat) { const task = NEEDLE_progressive.assignTextureLOD(mat, 0); if (task instanceof Promise) progressiveLoading.push(new Promise<void>((resolve, reject) => { task.then(() => { progressiveTasks++; Progress.report("export-usdz-textures", { message: "Loaded progressive texture", currentStep: progressiveTasks, totalSteps: progressiveLoading.length }); resolve(); }).catch((err) => reject(err)); })); } } } if (debug) showBalloonMessage("Progressive Loading: " + progressiveLoading.length); await Promise.all(progressiveLoading); if (debug) showBalloonMessage("Progressive Loading: done"); Progress.end("export-usdz-textures"); // apply XRFlags const currentXRState = XRState.Global.Mask; XRState.Global.Set(XRStateFlag.AR); const exporter = new ThreeUSDZExporter(); // We're creating a new animation extension on each export to avoid issues with multiple exports. // TODO we probably want to do that with all the extensions... // Ordering of extensions is important const animExt = new AnimationExtension(this.quickLookCompatible); let physicsExt: PhysicsExtension | undefined = undefined; const defaultExtensions: IUSDExporterExtension[] = []; if (this.interactive) { defaultExtensions.push(new BehaviorExtension()); defaultExtensions.push(new AudioExtension()); // If physics are enabled, and there are any Rigidbody components in the scene, // add the PhysicsExtension to the default extensions. if (globalThis["NEEDLE_USE_RAPIER"]) { const rigidbodies = GameObject.getComponentsInChildren(objectToExport, Rigidbody); if (rigidbodies.length > 0) { if (this.physics) { physicsExt = new PhysicsExtension(); defaultExtensions.push(physicsExt); } else if (isDevEnvironment()) { console.warn("USDZExporter: Physics export is disabled, but there are active Rigidbody components in the scene. They will not be exported."); } } } defaultExtensions.push(new TextExtension()); defaultExtensions.push(new USDZUIExtension()); } const extensions: any = [animExt, ...defaultExtensions, ...this.extensions]; const eventArgs = { self: this, exporter: exporter, extensions: extensions, object: objectToExport }; Progress.report("export-usdz", "Invoking before-export"); this.dispatchEvent(new CustomEvent("before-export", { detail: eventArgs })); // make sure we apply the AR scale this.applyWebARSessionRoot(); // freeze time this._previousTimeScale = this.context.time.timeScale; this.context.time.timeScale = 0; // Implicit registration and actions for Animators and Animation components // Currently, Animators properly build PlayAnimation actions, but Animation components don't. Progress.report("export-usdz", "auto export animations and audio sources"); const implicitBehaviors = new Array<Object3D>(); if (this.autoExportAnimations) { implicitBehaviors.push(...registerAnimatorsImplictly(objectToExport, animExt)); } const audioExt = extensions.find(ext => ext.extensionName === "Audio"); if (audioExt && this.autoExportAudioSources) implicitBehaviors.push(...registerAudioSourcesImplictly(objectToExport, audioExt as AudioExtension)); //@ts-ignore exporter.debug = debug; exporter.pruneUnusedNodes = !debugUsdzPruning; const instancedRenderers = InstancingHandler.instance.objs.map(x => x.batchedMesh); exporter.keepObject = (object) => { let keep = true; // This explicitly removes geometry and material data from disabled renderers. // Note that this is different to the object itself being active – // here, we have an active object with a disabled renderer. const renderer = GameObject.getComponent(object, Renderer); if (renderer && !renderer.enabled) keep = false; // Check if this is an instancing container. // Instances are already included in the export. if (keep && instancedRenderers.includes(object as any)) keep = false; if (keep && GameObject.getComponentInParent(object, ContactShadows)) keep = false; if (keep && GameObject.getComponentInParent(object, GroundProjectedEnv)) keep = false; if (debug && !keep) console.log("USDZExporter: Discarding object", object); return keep; } exporter.beforeWritingDocument = () => { // Warn if there are any physics components on animated objects or their children if (isDevEnvironment() && animExt && physicsExt) { const animatedObjects = animExt.animatedRoots; for (const object of animatedObjects) { const rigidBodySources = GameObject.getComponentsInChildren(object, Rigidbody).filter(c => c.enabled); const colliderSources = GameObject.getComponents(object, Collider).filter(c => c.enabled && !c.isTrigger); if (rigidBodySources.length > 0 || colliderSources.length > 0) { console.error("An animated object has physics components in its child hierarchy. This can lead to undefined behaviour due to a bug in Apple's QuickLook (FB15925487). Remove the physics components from child objects or verify that you get the expected results.", object); } } } }; // Collect invisible objects so that we can disable them if // - we're exporting for QuickLook // - and interactive behaviors are allowed. // When exporting for regular USD, we're supporting the "visibility" attribute anyways. const objectsToDisableAtSceneStart = new Array<Object3D>(); if (this.objectToExport && this.quickLookCompatible && this.interactive) { this.objectToExport.traverse((obj) => { if (!obj.visible) { objectsToDisableAtSceneStart.push(obj); } }); } const behaviorExt = extensions.find(ext => ext.extensionName === "Behaviour") as BehaviorExtension | undefined; if (this.interactive && behaviorExt && objectsToDisableAtSceneStart.length > 0) { behaviorExt.addBehavior(disableObjectsAtStart(objectsToDisableAtSceneStart)); } let exportInvisible = true; // The only case where we want to strip out invisible objects is // when we're exporting for QuickLook and we're NOT adding interactive behaviors, // since QuickLook on iOS does not support "visibility" tokens. if (this.quickLookCompatible && !this.interactive) exportInvisible = false; // sanitize anchoring types if (this.anchoringType !== "plane" && this.anchoringType !== "none" && this.anchoringType !== "image" && this.anchoringType !== "face") this.anchoringType = "plane"; if (this.planeAnchoringAlignment !== "horizontal" && this.planeAnchoringAlignment !== "vertical" && this.planeAnchoringAlignment !== "any") this.planeAnchoringAlignment = "horizontal"; Progress.report("export-usdz", "Invoking exporter.parse"); //@ts-ignore const arraybuffer = await exporter.parse(this.objectToExport, { ar: { anchoring: { type: this.anchoringType, }, planeAnchoring: { alignment: this.planeAnchoringAlignment, }, }, extensions: extensions, quickLookCompatible: this.quickLookCompatible, maxTextureSize: this.maxTextureSize, exportInvisible: exportInvisible, }); const blob = new Blob([arraybuffer], { type: 'model/vnd.usdz+zip' }); this.revertWebARSessionRoot(); // unfreeze time this.context.time.timeScale = this._previousTimeScale; Progress.report("export-usdz", "Invoking after-export"); this.dispatchEvent(new CustomEvent("after-export", { detail: eventArgs })) // cleanup – implicit animation behaviors need to be removed again for (const go of implicitBehaviors) { GameObject.destroy(go); } // restore XR flags XRState.Global.Set(currentXRState); // second file: USDA (without assets) //@ts-ignore // const usda = exporter.lastUsda; // const blob2 = new Blob([usda], { type: 'text/plain' }); // this.link.download = name + ".usda"; // this.link.href = URL.createObjectURL(blob2); // this.link.click(); Progress.end("export-usdz"); return blob; } /** * Opens QuickLook on iOS/iPadOS/visionOS with the given content in AR mode. * @param content The URL to the .usdz or .reality file or a blob containing an USDZ file. * @param name Download filename */ openInQuickLook(content: Blob | string, name: string) { const url = content instanceof Blob ? URL.createObjectURL(content) : content; // see https://developer.apple.com/documentation/arkit/adding_an_apple_pay_button_or_a_custom_action_in_ar_quick_look const overlay = this.buildQuicklookOverlay(); if (debug) console.log("QuickLook Overlay", overlay); const callToAction = overlay.callToAction ? encodeURIComponent(overlay.callToAction) : ""; const checkoutTitle = overlay.checkoutTitle ? encodeURIComponent(overlay.checkoutTitle) : ""; const checkoutSubtitle = overlay.checkoutSubtitle ? encodeURIComponent(overlay.checkoutSubtitle) : ""; this.link.href = url + `#callToAction=${callToAction}&checkoutTitle=${checkoutTitle}&checkoutSubtitle=${checkoutSubtitle}&callToActionURL=${overlay.callToActionURL}`; if (!this.lastCallback) { this.lastCallback = this.quicklookCallback.bind(this) this.link.addEventListener('message', this.lastCallback); } // Open QuickLook this.link.download = name + ".usdz"; this.link.click(); // cleanup // TODO check if we can do that immediately or need to wait until the user returns // if (content instanceof Blob) URL.revokeObjectURL(url); } /** * Downloads the given blob as a file. */ download(blob: Blob, name: string) { USDZExporter.save(blob, name); } // Matches GltfExport.save(blob, filename) private static save(blob, filename) { const link = document.createElement('a'); link.style.display = 'none'; document.body.appendChild(link); // Firefox workaround, see #6594 if (typeof blob === "string") link.href = blob; else link.href = URL.createObjectURL(blob); link.download = filename; link.click(); link.remove(); // console.log(link.href); // URL.revokeObjectURL( url ); breaks Firefox... } private lastCallback?: any; private quicklookCallback(event: Event) { if ((event as any)?.data == '_apple_ar_quicklook_button_tapped') { if (debug) showBalloonWarning("Quicklook closed via call to action button"); var evt = new CustomEvent("quicklook-button-tapped", { detail: this }); this.dispatchEvent(evt); if (!evt.defaultPrevented) { const url = new URLSearchParams(this.link.href); if (url) { const callToActionURL = url.get("callToActionURL"); if (debug) showBalloonMessage("Quicklook url: " + callToActionURL); if (callToActionURL) { if (!hasProLicense()) { console.warn("Quicklook closed: custom redirects require a Needle Engine Pro license: https://needle.tools/pricing", callToActionURL) } else { globalThis.open(callToActionURL, "_blank"); } } } } } } private buildQuicklookOverlay(): CustomBranding { const obj: CustomBranding = {}; if (this.customBranding) Object.assign(obj, this.customBranding); if (!hasProLicense()) { console.log("Custom Quicklook banner text requires pro license: https://needle.tools/pricing"); obj.callToAction = "Close"; obj.checkoutTitle = "🌵 Made with Needle"; obj.checkoutSubtitle = "_"; } const needsDefaultValues = obj.callToAction?.length || obj.checkoutTitle?.length || obj.checkoutSubtitle?.length; if (needsDefaultValues) { if (!obj.callToAction?.length) obj.callToAction = "\0"; if (!obj.checkoutTitle?.length) obj.checkoutTitle = "\0"; if (!obj.checkoutSubtitle?.length) obj.checkoutSubtitle = "\0"; } // Use the quicklook-overlay event to customize the overlay this.dispatchEvent(new CustomEvent("quicklook-overlay", { detail: obj })); return obj; } private static invertForwardMatrix = new Matrix4().makeRotationY(Math.PI); private static invertForwardQuaternion = new Quaternion().setFromEuler(new Euler(0, Math.PI, 0)); private _rootSessionRootWasAppliedTo: Object3D | null = null; private _rootPositionBeforeExport: Vector3 = new Vector3(); private _rootRotationBeforeExport: Quaternion = new Quaternion(); private _rootScaleBeforeExport: Vector3 = new Vector3(); getARScaleAndTarget(): { scale: number, _invertForward: boolean, target: Object3D, sessionRoot: Object3D | null} { if (!this.objectToExport) return { scale: 1, _invertForward: false, target: this.gameObject, sessionRoot: null}; const xr = GameObject.findObjectOfType(WebXR); let sessionRoot = GameObject.getComponentInParent(this.objectToExport, WebARSessionRoot); if(!sessionRoot) sessionRoot = GameObject.getComponentInChildren(this.objectToExport, WebARSessionRoot); let arScale = 1; let _invertForward = false; const target = this.objectToExport; // Note: when USDZ is exported, SessionRoot might not have the correct AR scale, since that is populated upon EnterXR from WebXR. if (xr) { arScale = xr.arScale; } else if (sessionRoot) { arScale = sessionRoot.arScale; // eslint-disable-next-line deprecation/deprecation _invertForward = sessionRoot.invertForward; } const scale = 1 / arScale; const result = { scale, _invertForward, target, sessionRoot: sessionRoot?.gameObject ?? null }; return result; } private applyWebARSessionRoot() { if (!this.objectToExport) return; const { scale, _invertForward, target, sessionRoot } = this.getARScaleAndTarget(); const sessionRootMatrixWorld = sessionRoot?.matrixWorld.clone().invert(); this._rootSessionRootWasAppliedTo = target; this._rootPositionBeforeExport.copy(target.position); this._rootRotationBeforeExport.copy(target.quaternion); this._rootScaleBeforeExport.copy(target.scale); target.scale.multiplyScalar(scale); if (_invertForward) target.quaternion.multiply(USDZExporter.invertForwardQuaternion); // udate childs as well target.updateMatrix(); target.updateMatrixWorld(true); if (sessionRoot && sessionRootMatrixWorld) target.matrix.premultiply(sessionRootMatrixWorld); } private revertWebARSessionRoot() { if (!this.objectToExport) return; if (!this._rootSessionRootWasAppliedTo) return; const target = this._rootSessionRootWasAppliedTo; target.position.copy(this._rootPositionBeforeExport); target.quaternion.copy(this._rootRotationBeforeExport); target.scale.copy(this._rootScaleBeforeExport); // udate childs as well target.updateMatrix(); target.updateMatrixWorld(true); this._rootSessionRootWasAppliedTo = null; } private createQuicklookButton() { const buttoncontainer = WebXRButtonFactory.getOrCreate(); const button = buttoncontainer.createQuicklookButton(); if(!button.parentNode) this.context.menu.appendChild(button); return button; } }