diff --git a/fixtures/spatial-externalmesh-glb.ts b/fixtures/spatial-externalmesh-glb.ts new file mode 100644 index 0000000..0ffdd02 --- /dev/null +++ b/fixtures/spatial-externalmesh-glb.ts @@ -0,0 +1 @@ +// TODO \ No newline at end of file diff --git a/fixtures/spatial-externalmesh-glb.xsml b/fixtures/spatial-externalmesh-glb.xsml index 759f436..2bc47c1 100644 --- a/fixtures/spatial-externalmesh-glb.xsml +++ b/fixtures/spatial-externalmesh-glb.xsml @@ -6,7 +6,5 @@ - + diff --git a/src/impl-headless.ts b/src/impl-headless.ts index c6b0fb9..3ad0f40 100644 --- a/src/impl-headless.ts +++ b/src/impl-headless.ts @@ -117,6 +117,8 @@ export class HeadlessNativeDocument extends EventTarget implements NativeDocumen closed: boolean = false; private _scene: BABYLON.Scene; + private _preloadMeshes: Map> = new Map(); + private _preloadAnimationGroups: Map = new Map(); constructor() { super(); @@ -136,11 +138,11 @@ export class HeadlessNativeDocument extends EventTarget implements NativeDocumen getContainerPose(): XRPose { throw new Error('Method not implemented.'); } - getPreloadedMeshes(): Map { - throw new Error('Method not implemented.'); + getPreloadedMeshes(): Map> { + return this._preloadMeshes; } getPreloadedAnimationGroups(): Map { - throw new Error('Method not implemented.'); + return this._preloadAnimationGroups; } observeInputEvent(name?: string): void { throw new Error('Method not implemented.'); diff --git a/src/impl-interfaces.ts b/src/impl-interfaces.ts index 3ed6377..65b0690 100644 --- a/src/impl-interfaces.ts +++ b/src/impl-interfaces.ts @@ -184,7 +184,7 @@ export interface NativeDocument extends EventTarget { * * When loading a XSML document, the implementation should preload the specific meshes. */ - getPreloadedMeshes(): Map; + getPreloadedMeshes(): Map>; /** * It returns a map of preloaded animation groups. */ diff --git a/src/living/nodes/HTMLLinkElement.ts b/src/living/nodes/HTMLLinkElement.ts index 09e33f8..c517936 100644 --- a/src/living/nodes/HTMLLinkElement.ts +++ b/src/living/nodes/HTMLLinkElement.ts @@ -176,21 +176,56 @@ export default class HTMLLinkElementImpl extends HTMLElementImpl implements HTML } private async _loadStylesheet() { - // TODO + throw new DOMException('Not implemented yet.', 'NOT_SUPPORTED_ERR'); } private async _loadSpatialModel() { - const nativeDocument = this._hostObject; - const url = new URL(this.href, this._ownerDocument._URL); - const resArrayBuffer = await nativeDocument.userAgent.resourceLoader.fetch(url.href, {}, 'arraybuffer'); - const extname = path.extname(url.pathname); - const importedResult = await BABYLON.SceneLoader.ImportMeshAsync( - '', - '', - new Uint8Array(resArrayBuffer), - nativeDocument.getNativeScene(), - null, - extname - ); + const id = this.getAttribute('id'); + if (id == null) { + throw new DOMException('The id attribute is required for spatial-model.', 'INVALID_STATE_ERR'); + } + + try { + const nativeDocument = this._hostObject; + const url = new URL(this.href, this._ownerDocument._URL); + const resArrayBuffer = await nativeDocument.userAgent.resourceLoader.fetch(url.href, {}, 'arraybuffer'); + const extname = path.extname(url.pathname); + const importedResult = await BABYLON.SceneLoader.ImportMeshAsync( + '', + '', + new Uint8Array(resArrayBuffer), + nativeDocument.getNativeScene(), + null, + extname + ); + + const transformNodesAndMeshes = importedResult.transformNodes.concat(importedResult.meshes); + for (const node of transformNodesAndMeshes) { + node.setEnabled(false); + /** + * If the extension is .glb or .gltf, we assume the nodes are in right-handed system. + * + * Babylon.js doesn't convert the mesh to left-handed system even though the loader and scene are in left-handed system, it + * works with root node's rotation and scaling correction and the renderer, thus it doesn't work for our case, that requires + * the right data in the left-handed system for mesh itself. + * + * Thus we add a tag to the node to indicate that the mesh is in right-handed system, and we will convert it to left-handed + * at serializer. + */ + if (extname === '.glb' || extname === '.gltf') { + /** + * The tag will be copied when this node is cloned, so this is a exact match for our use case and don't need to handle the + * same tagging on the cloned nodes. + */ + BABYLON.Tags.AddTagsTo(node, 'right-handed-system'); + } + } + this._hostObject.getPreloadedMeshes().set(id, transformNodesAndMeshes); + if (importedResult.animationGroups?.length > 0) { + this._hostObject.getPreloadedAnimationGroups().set(id, importedResult.animationGroups); + } + } catch (err) { + throw new DOMException(`Failed to load spatial model(${this.href}): ${err.message}`, 'INVALID_STATE_ERR'); + } } } diff --git a/src/living/nodes/SpatialMeshElement.ts b/src/living/nodes/SpatialMeshElement.ts index f06cde5..29c3398 100644 --- a/src/living/nodes/SpatialMeshElement.ts +++ b/src/living/nodes/SpatialMeshElement.ts @@ -1,6 +1,27 @@ +import BABYLON from 'babylonjs'; import { NativeDocument } from '../../impl-interfaces'; +import DOMExceptionImpl from '../domexception'; import { SpatialElement } from './SpatialElement'; +export function cloneWithOriginalRefs( + source: BABYLON.Node, + name: string, + newParent: BABYLON.Node, + onClonedObversaval?: (origin: BABYLON.Node, cloned: BABYLON.Node) => void +) { + const result = source.clone(name, newParent, true); + result.setEnabled(true); + + if (typeof onClonedObversaval === 'function') { + onClonedObversaval(source, result); + } + const directDescendantsOfSource = source.getDescendants(true); + for (let child of directDescendantsOfSource) { + cloneWithOriginalRefs(child, child.name, result, onClonedObversaval); + } + return result; +} + export default class SpatialMeshElement extends SpatialElement { constructor( hostObject: NativeDocument, @@ -12,8 +33,120 @@ export default class SpatialMeshElement extends SpatialElement { }); } + get ref(): string { + return this.getAttribute('ref'); + } + set ref(value: string) { + this.setAttribute('ref', value); + } + + get selector(): string { + return this.getAttribute('selector') || '__root__'; + } + set selector(value: string) { + this.setAttribute('selector', value); + } + _attach(): void { - super._attach(); - // TODO + super._attach(this._instantiate()); + } + + _instantiate(): BABYLON.Node { + const { ref, selector } = this; + + if (!ref) { + throw new DOMExceptionImpl('ref is required in ', 'INVALID_STATE_ERR'); + } + if (!this._hostObject.getPreloadedMeshes().has(ref)) { + throw new DOMExceptionImpl(`No mesh with ref(${ref}) is preloaded`, 'INVALID_STATE_ERR'); + } + + const transformNodesAndMeshes = this._hostObject.getPreloadedMeshes().get(ref); + if (!transformNodesAndMeshes || !transformNodesAndMeshes.length) { + throw new DOMExceptionImpl(`No mesh or transforms with ref(${ref}) is found from preloaded resource`, 'INVALID_STATE_ERR'); + } + + const targetMesh = transformNodesAndMeshes.find((node) => node.name === selector); + if (!targetMesh) { + throw new DOMExceptionImpl(`No mesh or transform with selector(${selector}) is found from preloaded resource`, 'INVALID_STATE_ERR'); + } + + const rootName = this.id || this.getAttribute('name') || this.localName; + const originUidToClonedMap: { [key: number]: BABYLON.Node } = {}; + const clonedMesh = cloneWithOriginalRefs(targetMesh, rootName, null, (origin, cloned) => { + originUidToClonedMap[origin.uniqueId] = cloned; + }); + + /** Set the cloned skeleton, it set the new bone's linking transforms to new. */ + clonedMesh.getChildMeshes().forEach((childMesh) => { + if (childMesh.skeleton) { + const clonedSkeleton = childMesh.skeleton.clone(`${childMesh.skeleton.name}-cloned`); + clonedSkeleton.bones.forEach(bone => { + const linkedTransformNode = bone.getTransformNode(); + if (!originUidToClonedMap[linkedTransformNode.uniqueId]) { + throw new TypeError(`Could not find the linked transform node(${linkedTransformNode.name}) for bone(${bone.name})`); + } else { + const newTransform = originUidToClonedMap[linkedTransformNode.uniqueId]; + if (newTransform.getClassName() === 'TransformNode') { + bone.linkTransformNode(newTransform as BABYLON.TransformNode); + } else { + throw new TypeError(`The linked transform node(${linkedTransformNode.name}) for bone(${bone.name}) is not a transform node, actual type is "${newTransform.getClassName()}".`); + } + } + }); + childMesh.skeleton = clonedSkeleton; + } + }); + + /** + * The Babylonjs won't clone the animation group when the related meshes are cloned. + * + * In this case, we need to clone the animation groups manually, and set the new targets + * to the cloned meshes and its related animation groups. + */ + const animationGroups = this._hostObject.getPreloadedAnimationGroups().get(ref); + if (animationGroups) { + for (let i = 0; i < animationGroups.length; i++) { + const animationGroup = animationGroups[i]; + if (animationGroup.targetedAnimations.length <= 0) { + continue; + } + let id = `${i}`; + if (this.id) { + id = `#${this.id}`; + } + const newAnimationGroup = animationGroup.clone( + `${animationGroup.name}/${id}`, + (oldTarget: BABYLON.Node) => originUidToClonedMap[oldTarget.uniqueId], + false + ); + + /** + * When an animation group is cloned into space, we added the following listeners to mark + * the animation group as dirty. + */ + function markDirty(animationGroup: BABYLON.AnimationGroup) { + /** FIXME(Yorkie): we use the metadata._isDirty as the dirty-update flag for animation group */ + if (!animationGroup.metadata) { + animationGroup.metadata = {}; + } + animationGroup.metadata._isDirty = true; + } + newAnimationGroup.onAnimationGroupPlayObservable.add(markDirty); + newAnimationGroup.onAnimationGroupEndObservable.add(markDirty); + newAnimationGroup.onAnimationGroupPauseObservable.add(markDirty); + + // If the animation group is playing, stop it and start the new one. + if (animationGroup.isPlaying === true) { + try { + newAnimationGroup.start(animationGroup.loopAnimation); + } catch (err) { + console.warn(`Failed to start the animation group(${newAnimationGroup.name}): ${err.message}`); + } + animationGroup.stop(); + } + } + } + return clonedMesh; } }