3
votes

Motivation
I have an Angular 6 app that has multiple A-Frame scenes. I would like to inject into each scene some common "partials" (in rails lingo) containing A-Frame HTML elements such as an afame-gui config panel. This way, I don't duplicate the html elements in each view. This is a standard thing to do in ng6, and you do it by creating an ng component for the partial, which then gets its own tag, which you then reference in each parent component you want to mix it into:

   <a-scene>
      <app-config-panel></app-config-panel>  <-- insert partial #1
      <ng-template appDynamicLoad></ng-template> <-- insert partial #2
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" shadow></a-box>

I have a fuller example here

Problem
Unfortunately, while Angular 6 does inject the partial into the DOM, I do not see it in the A-Frame scene.

I know that A-Frame has special wrapped createElement, setAttribute, and appendChild methods that override the default behavior and add additional info, such as a THREE.object3D to each element. Presumably, angular 6 is not using these methods for inserting into the DOM (note, this only applies to partials: the "main" component containing the scene works properly). Actually, in inspecting the DOM elements, I do see an Object3D, but it doesn't have any Material or Geometry info under it.

So my question is: Is there a way to make Angular drive these methods and thus insert into the A-Frame scene as well?

Details
I found this that mentions a way to force angular to call setAttribute and thus properly insert it into the a-frame scene, but there wasn't enough information for me to figure it out.

I tried dynamically loading the component based on this convoluted example from angular docs, but its the same thing: inserts into DOM but not the scene.

Yes, I can manually build up and insert the element programmatically from my script, something like this:

createEl() {
    let newEl = this.renderer.createElement('a-entity');

    this.renderer.setAttribute(newEl, 'text', this.msg);
    this.renderer.setAttribute(newEl, 'position', this.position);

    return newEl;
  }

But I'd rather do it using a template in the official angular way.

It's odd that angular works fine for the parent component, but not for the partial component.

I fear this is simply an A-frame/Angular impedance mismatch and I'll just be forced to do it manually, but it never hurts to ask, right?

Angular 6.0.3
A-Frame 0.8.2

2

2 Answers

2
votes

Use A-frame's registerPrimitve to make a-frame think the ng partial is a "native" a-frame entity. This is based on the second solution of my prior write-up which I actually tested and verified, and is a much better and simpler solution.

You simply need to call something like the following from a .js file to register your ng tags (in my particular case, I chose to place the script at "--ng-root--/init_scripts/register-ng-primitives.js'):

// Register all the ng-generated tags in your application so that a-frame
// recognizes them as one of it its own, and thus properly inserts them into the
// scene.  If ng tags are not registered with a-frame then ng will only insert
// them into to the DOM but *not* into the a-frame scene.
// A-frame's 'registerPrimitive' *must* be called in angular's polyfill.js after
// 'aframe.js' and before 'zone.js'.
//
AFRAME.registerPrimitive('app-sub-scene-a', { mappings: {} }); //<- specify all your ng tags starting here
AFRAME.registerPrimitive('app-sub-scene-b', { mappings: {} });

Refer to the a-frame docs for more detail about registering primitives.

The only complication in an angular environment is where this needs to be done. You need to do call registerPrimitve after aframe.js has been loaded (so AFRAME is defined) but before "zone.js" has been called.

You can do this by customizing your 'src/polyfill.js' like so:

//import 'aframe'; <- alternate way to load aframe
import '../node_modules/aframe/dist/aframe-master'; //<- useful if you want to call a specific version of a-frame
import '../init_scripts/register-ng-primitives'; <- put your script here


/***************************************************************************************************
 * Zone JS is required by default for Angular itself.
 */
import 'zone.js/dist/zone';  // Included with Angular CLI. <- need to call before this

Note: it seems that polyfill.js doesn't allow loading from anywhere under '/src', so obvious places to place your custom script are in 'node_modules' or in some other directory in the angular root. I chose to create a user directory called 'init_scripts' because I feel _node_modules_ should be reserved for system code, plus you'll want to add this script to your repo.

If you attempt to call registerPrimitive after zone.js is initialized, you'll get a message like:

TypeError: "detachedCallBack" is read-only.  

So that's why it needs to be defined before. See this link for more detail. This 'detachedCallBack' problem is only a problem when running in an angular context.

That's pretty much it. After you do this you should see any a-frame entities defined in your partials showing up in the scene. Using parials becomes very important because it appears that a-frame effectively only supports one <a-scene> per page (and since Angular is a SPA, it in effect is a single physical page, with multiple logical pages as routes). See here and here for more detail. Thus in effect you have to insert logical scenes as sub-scenes and use visibility attributes to determine what's actually displayed.

Something like:

<a-scene>
  <a-entity laser-controls="hand: right"></a-entity>
  <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4" shadow></a-plane>
  <a-circle
    position="0 3 -5"
    color="#a3ff6e"
    (click)="toggleSubScenes($event)"
  ></a-circle>
  <app-sub-scene-a></app-sub-scene-a>
  <app-sub-scene-b></app-sub-scene-b>
</a-scene>

Where toggleSubScene looks something like:

  toggleSubScenes() {
    console.log(`WrapperSceneComponent.toggleSubScenes: entered`);
    let ssa = document.querySelector('app-sub-scene-a');
    let ssb = document.querySelector('app-sub-scene-b');
    let ssaVisible : any= ssa.getAttribute('visible');
    let ssbVisible : any= ssb.getAttribute('visible');

    ssa.setAttribute('visible', ssaVisible ? 'false' : 'true');
    ssb.setAttribute('visible', ssbVisible ? 'false' : 'true');
  }

Empirically, I am unable to maintain "vr-mode" acrosss multiple "a-scenes" without requiring a user-gesture to re-enter VR. Thus I have to create one global 'a-scene' and define my prior stand-alone scenes as sub-scene partials injected into the main a-scene.

1
votes

So after a big debug session, I think I've found out what the problem is. The only problem is the "solution" is not really any better than the workaround of creating the component directly.

Caveat: of course, it goes without saying that my analysis is conjecture on my part (based on empirical observation), and I do not claim to be an authority on the a-frame source code itself.

TLDR;
appendChild appears to be the main a-frame hook to inject itself into the DOM and the scene. However, a-frame wisely appears to restrict it's enhanced (or "fat") version of appendChild to tags recognizable by a-frame (e.g. 'a-cube', 'a-scene', 'a-blah'..etc). It does not use this "fat" version of appendChild on non-a-frame elements. Any element injected by angular (e.g. 'app-my-angular-tag') will not be recognized by a-frame and thus will simply use the "thin" or standard version of appendChild, thus inserting into the DOM only.

Detail
It appears that A-frame has enhanced versions of createElement, appendChild, and probably getAttribute and setAttribute as well. createElement inserts the object3D into the elements, and it appears that angular is using createElement to create its elements, which explains why I did see uninitialized object3D's in the partial's a-frame elements (every element has to be created individually, thus even if the parent element is not recognized by a-frame, the children elements that are a-frame will be processed as such).

Note: the parent component is wrapped in an a-scene tag and is thus recognized by a-frame and fat injected. This explains why the parent component is properly created. The partial is always wrapped in some parent element created by angular. If the partial were wrapped in some a-frame tag like a-sub-scene, then I would expect it to be properly injected.

However, because the tree of created elements are inserted as a single chunk, a-frame will only look at the type of the parent element, which will be non-a-frame in the case of an injected angular tag. Thus, as explained in the TLDR, the fat version of appendChild is not called, and the object3D environment is not set up, and the element is not injected into the a-frame scene.

Solution(s)
1) After the a-scene has been loaded, you have to loop over the a-frame elements of the injected angular element and re-apply appendChild, which will now use the fat version and properly insert into the scene (full example here)

Here is some proof of concept code:

Parent Component:

 <body>
    <a-scene debug="true">
      <app-config-panel></app-config-panel> <-- ng inserts here
      <a-circle radius="0" id="config-hook-circle"></a-circle> <--Note: reinsertion point
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" shadow></a-box>
      <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4" shadow></a-plane>
      <a-sky color="#ECECEC"></a-sky>
    </a-scene>
  </body>

Injected child component template (config-panel.component.html):

<a-entity id="acp">
  <a-sphere color="yellow" radius=".2" position="0 0 -3.5"></a-sphere>
  <a-cylinder color="red" height=".5" radius=".3" position="-1 2 -3.5"></a-cylinder>
</a-entity>

Call re-inject after component load (in parent component):

ngOnInit() {
document.querySelector('a-scene')
  .addEventListener('loaded', () => {
    this.reinjectConfigPanel();
  })
}

And the code to re-inject (note: tied to the individual template structure, very brittle and not a general solution):

  reinjectConfigPanel(){
    let acp=document.querySelector('app-config-panel'); 
    let acpClone  = (acp.cloneNode(true) as Element);
    let hook = document.querySelector('#config-hook-circle');

    let numChildren = acpClone.children[0].children.length;
    for (let i=0; i < numChildren; i++) {
      // Note: after you appendChild node 0, then the next node becomes 0, so we always
      // use child 0
      hook.appendChild(acpClone.children[0].children[0]) // uses fat version of appendChild
    }
  }

As you can see, by the time everything is done you've essentially re-injected everything so you might as well just inject it directly in the first place. However, this does work and I wanted to show that it's possible (as well as validating my theory about the fat appendChild). There might be a lot of advantages such as variable binding that is easier to do in a template, so there could be use cases where you need to do something like this.

2) Get A-Frame to recognize your angular tag as an "official" a-frame tag. I didn't really investigate this line of attack, other than to speculate that calling "registerPrimitive" might be a way to achieve this:

AFRAME.registerPrimitive('app-config-panel', { defaultComponents: {}, mappings: {} })

Hopefully, by doing this you would trick a-frame into using the fat appendChild and thus add your partial to the a-scene. However, this would have to be done before a-frame creates the scene, and the only way I could think to do that would be to modify the a-frame source itself (?)