Brick Viewer mit Lichtelementen bauen

1. Einführung

Webkomponenten

Webkomponenten sind eine Sammlung von Webstandards, mit denen Entwickler HTML mit benutzerdefinierten Elementen erweitern können. In diesem Codelab definieren Sie das <brick-viewer>-Element, mit dem Steinmodelle angezeigt werden können.

lit-element

Zur Definition des benutzerdefinierten Elements <brick-viewer> verwenden wir lit-element. lit-element ist eine einfache Basisklasse, die dem Webkomponenten-Standard etwas syntaktischen Zucker hinzufügt. Dies erleichtert uns den Einstieg in unser benutzerdefiniertes Element.

Jetzt starten

Wir werden in einer Online-Stackblitz-Umgebung programmieren. Öffnen Sie dazu diesen Link in einem neuen Fenster:

stackblitz.com/edit/brick-viewer

Los gehts!

2. Benutzerdefiniertes Element definieren

Klassendefinition

Wenn Sie ein benutzerdefiniertes Element definieren möchten, erstellen Sie eine Klasse, die LitElement erweitert, und versehen Sie sie mit @customElement. Das Argument für @customElement ist der Name des benutzerdefinierten Elements.

Fügen Sie in „brick-viewer.ts“ Folgendes ein:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}

Jetzt kann das <brick-viewer></brick-viewer>-Element in HTML verwendet werden. Wenn Sie es jedoch versuchen, wird nichts gerendert. Das sollte nicht sein.

Renderingmethode

Um die Ansicht der Komponente zu implementieren, definieren Sie eine Methode mit dem Namen „render“. Diese Methode sollte ein Vorlagenliteral zurückgeben, das mit der Funktion html getaggt ist. Geben Sie den gewünschten HTML-Code in das getaggte Vorlagenliteral ein. Dieser wird gerendert, wenn Sie <brick-viewer> verwenden.

Fügen Sie die Methode render hinzu:

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick viewer</div>`;
  }
}

3. LDraw-Datei angeben

Property definieren

Es wäre großartig, wenn ein Nutzer von <brick-viewer> mithilfe eines Attributs angeben könnte, welches Ziegelmodell angezeigt werden soll:

<brick-viewer src="path/to/model.ldraw"></brick-viewer>

Da wir ein HTML-Element erstellen, können wir die deklarative API nutzen und ein Quellattribut definieren, genau wie ein <img>- oder <video>-Tag. Mit lit-element können Sie eine Klasseneigenschaft ganz einfach mit @property ausstatten. Mit der Option type können Sie angeben, wie die Property von lit-Elementen für die Verwendung als HTML-Attribut geparst wird.

Definieren Sie die Property und das Attribut src:

export class BrickViewer extends LitElement {
  @property({type: String})
  src: string|null = null;
}

<brick-viewer> hat jetzt ein src-Attribut, das wir in HTML festlegen können. Sein Wert ist dank des Lit-Elements bereits innerhalb unserer BrickViewer-Klasse lesbar.

Werte anzeigen

Wir können den Wert des Attributs src anzeigen, indem wir es im Vorlagenliteral der Rendermethode verwenden. Werte mithilfe der ${value}-Syntax in Vorlagenliterale einfügen

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick model: ${this.src}</div>`;
  }
}

Jetzt sehen wir im Fenster den Wert des Attributs „src“ im Element <brick-viewer>. Öffnen Sie die Entwicklertools Ihres Browsers und ändern Sie das src-Attribut manuell. Probieren Sie es aus…

…Haben Sie bemerkt, dass sich der Text im Element automatisch aktualisiert? lit-element überwacht die mit @property verzierten Klasseneigenschaften und rendert die Ansicht für Sie neu. lit-element erledigt die schwere Arbeit, damit Sie es nicht tun müssen.

4. Mit Three.js die Szene festlegen

Licht, Kamera, Rendering!

Unser benutzerdefiniertes Element verwendet 3.js, um unsere 3D-Bausteinmodelle zu rendern. Einige Dinge möchten wir nur einmal für jede Instanz eines <brick-viewer>-Elements tun, z. B. die Three.js-Szene, die Kamera und die Beleuchtung einrichten. Wir fügen sie dem Konstruktor der Klasse „BrickViewer“ hinzu. Wir behalten einige Objekte als Klasseneigenschaften, damit wir sie später wiederverwenden können: Kamera, Szene, Steuerelemente und Renderer.

Fügen Sie die 3.js-Szeneneinrichtung hinzu:

export class BrickViewer extends LitElement {

  private _camera: THREE.PerspectiveCamera;
  private _scene: THREE.Scene;
  private _controls: OrbitControls;
  private _renderer: THREE.WebGLRenderer;

  constructor() {
    super();

    this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
    this._camera.position.set(150, 200, 250);

    this._scene = new THREE.Scene();
    this._scene.background = new THREE.Color(0xdeebed);

    const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
    this._scene.add( ambientLight );

    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(-1000, 1200, 1500);
    this._scene.add(directionalLight);

    this._renderer = new THREE.WebGLRenderer({antialias: true});
    this._renderer.setPixelRatio(window.devicePixelRatio);
    this._renderer.setSize(this.offsetWidth, this.offsetHeight);

    this._controls = new OrbitControls(this._camera, this._renderer.domElement);
    this._controls.addEventListener("change", () =>
      requestAnimationFrame(this._animate)
    );

    this._animate();

    const resizeObserver = new ResizeObserver(this._onResize);
    resizeObserver.observe(this);
  }

  private _onResize = (entries: ResizeObserverEntry[]) => {
    const { width, height } = entries[0].contentRect;
    this._renderer.setSize(width, height);
    this._camera.aspect = width / height;
    this._camera.updateProjectionMatrix();
    requestAnimationFrame(this._animate);
  };

  private _animate = () => {
    this._renderer.render(this._scene, this._camera);
  };
}

Das WebGLRenderer-Objekt stellt ein DOM-Element bereit, in dem die gerenderte three.js-Szene angezeigt wird. Der Zugriff erfolgt über die Property domElement. Wir können diesen Wert mithilfe der ${value}-Syntax in das Render-Template-Literal einfügen.

Entfernen Sie die src-Meldung aus der Vorlage und fügen Sie das DOM-Element des Renderers ein:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
    `;
  }
}

Damit das dom-Element des Renderers vollständig angezeigt wird, müssen wir auch das <brick-viewer>-Element selbst auf display: block setzen. Wir können Stile in einer statischen Property namens styles angeben, die auf ein css-Vorlagenliteral festgelegt ist.

Fügen Sie der Klasse diesen Stil hinzu:

export class BrickViewer extends LitElement {
  static styles = css`
    /* The :host selector styles the brick-viewer itself! */
    :host {
      display: block;
    }
  `;
}

Jetzt zeigt <brick-viewer> eine gerenderte Three.js-Szene an:

Ein Brick-Viewer-Element, das eine gerenderte, aber leere Szene zeigt.

Aber… er ist leer. Wir stellen dafür ein Modell bereit.

Brick-Ladeprogramm

Wir übergeben die zuvor definierte Eigenschaft src an den LDrawLoader, der mit three.js geliefert wird.

Mit LDraw-Dateien können Sie ein Ziegelmodell in separate Gebäudeschritte unterteilen. Die Gesamtzahl der Schritte und die Sichtbarkeit einzelner Steine sind über die LDrawLoader API zugänglich.

Kopieren Sie diese Eigenschaften, die neue Methode _loadModel und die neue Zeile im Konstruktor:

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
  private _loader = new LDrawLoader();
  private _model: any;
  private _numConstructionSteps?: number;
  step?: number;

  constructor() {
    // ...
    // Add this line right before this._animate();
    (this._loader as any).separateObjects = true;
    this._animate();
  }

  private _loadModel() {
    if (this.src === null) {
      return;
    }
    this._loader
        .setPath('')
        // Using our src property!
        .load(this.src, (newModel) => {

          if (this._model !== undefined) {
            this._scene.remove(this._model);
            this._model = undefined;
          }

          this._model = newModel;

          // Convert from LDraw coordinates: rotate 180 degrees around OX
          this._model.rotation.x = Math.PI;
          this._scene.add(this._model);

          this._numConstructionSteps = this._model.userData.numConstructionSteps;
          this.step = this._numConstructionSteps!;

          const bbox = new THREE.Box3().setFromObject(this._model);
          this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
          this._controls.update();
          this._controls.saveState();
        });
  }
}

Wann soll _loadModel aufgerufen werden? Sie muss jedes Mal aufgerufen werden, wenn sich das src-Attribut ändert. Durch das Dekorieren der src-Eigenschaft mit @property haben wir die Eigenschaft für den Aktualisierungszyklus des Litigation-Elements aktiviert. Immer wenn sich der Wert einer dieser dekorierten Eigenschaften ändert, wird eine Reihe von Methoden aufgerufen, die auf die neuen und alten Werte der Eigenschaften zugreifen können. Die Lebenszyklusmethode, an der wir interessiert sind, heißt update. Die Methode update verwendet ein PropertyValues-Argument, das Informationen zu allen gerade geänderten Attributen enthält. Hier können Sie _loadModel anrufen.

Fügen Sie die Methode update hinzu:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    super.update(changedProperties);
  }
}

Das Element <brick-viewer> kann jetzt eine mit dem Attribut src angegebene Brick-Datei anzeigen.

Ein Element, das ein Automodell zeigt

5. Teilmodelle anzeigen

Lassen Sie uns nun den aktuellen Konstruktionsschritt konfigurierbar machen. Wir möchten <brick-viewer step="5"></brick-viewer> angeben können und sollten sehen, wie das Modell aus Legosteinen im fünften Bauschritt aussieht. Dazu machen wir die Eigenschaft step zu einer beobachteten Eigenschaft, indem wir sie mit @property dekorieren.

Dekorieren Sie das Attribut step:

export class BrickViewer extends LitElement {
  @property({type: Number})
  step?: number;
}

Jetzt fügen wir eine Hilfsmethode hinzu, mit der nur die Blöcke bis zum aktuellen Bauschritt sichtbar sind. Wir rufen das Hilfsprogramm in der Aktualisierungsmethode auf, damit sie jedes Mal ausgeführt wird, wenn das Attribut step geändert wird.

Aktualisieren Sie die update-Methode und fügen Sie die neue _updateBricksVisibility-Methode hinzu:

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    if (changedProperties.has('step')) {
      this._updateBricksVisibility();
    }
    super.update(changedProperties);
  }

  private _updateBricksVisibility() {
    this._model && this._model.traverse((c: any) => {
      if (c.isGroup && this.step) {
        c.visible = c.userData.constructionStep <= this.step;
      }
    });
    requestAnimationFrame(this._animate);
  }
}

Öffnen Sie jetzt die devtools Ihres Browsers und prüfen Sie das Element <brick-viewer>. Fügen Sie dazu ein step-Attribut hinzu:

HTML-Code eines Brick-Viewer-Elements mit dem Attribut „Schritt“ auf „10“ festgelegt

Beobachten Sie, was mit dem gerenderten Modell geschieht. Mit dem Attribut step können Sie festlegen, wie viel vom Modell angezeigt wird. So sollte es aussehen, wenn das step-Attribut auf "10" festgelegt ist:

Ein Klemmbausteinmodell mit nur zehn Bauschritten.

6. Navigation im Set

mwc-icon-button

Der Endnutzer unseres <brick-viewer> sollte auch in der Lage sein, die Build-Schritte über die Benutzeroberfläche aufzurufen. Fügen wir Schaltflächen zum Wechseln zum nächsten, vorherigen und ersten Schritt hinzu. Wir verwenden die Schaltflächen-Webkomponente von Material Design, um es einfacher zu machen. Da @material/mwc-icon-button bereits importiert ist, können wir <mwc-icon-button></mwc-icon-button> einfügen. Wir können das gewünschte Symbol mit dem Attribut „icon“ angeben, z. B. so: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Alle möglichen Symbole finden Sie hier: material.io/resources/icons.

Fügen wir der Rendermethode einige Symbolschaltflächen hinzu:

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button icon="replay"></mwc-icon-button>
        <mwc-icon-button icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Dank Webkomponenten ist es so einfach, Material Design auf unserer Seite zu verwenden.

Ereignisbindungen

Diese Schaltflächen sollten tatsächlich etwas tun. Über die Schaltfläche „Antworten“ sollte der Erstellungsschritt auf 1 zurückgesetzt werden. Die Schaltfläche „navigate_before“ sollte den Bauschritt verringern und die Schaltfläche „navigate_next“ sollte ihn erhöhen. Mithilfe von Ereignisbindungen lässt sich diese Funktion mit lit-element ganz einfach hinzufügen. Verwenden Sie in Ihrem HTML-Vorlagenliteral die Syntax @eventname=${eventHandler} als Elementattribut. eventHandler wird jetzt ausgeführt, wenn ein eventname-Ereignis für dieses Element erkannt wird. Als Beispiel fügen wir unseren drei Symbolschaltflächen Click-Event-Handler hinzu:

export class BrickViewer extends LitElement {
  private _restart() {
    this.step! = 1;
  }

  private _stepBack() {
    this.step! -= 1;
  }

  private _stepForward() {
    this.step! += 1;
  }

  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

Versuche jetzt, auf die Schaltflächen zu klicken. Sehr gut!

Stile

Die Schaltflächen funktionieren, sehen aber nicht gut aus. Sie sitzen alle unten. Passen wir sie an, damit sie über der Szene liegen.

Um Stile auf diese Schaltflächen anzuwenden, kehren wir zur Eigenschaft static styles zurück. Da diese Stile auf einen Umfang beschränkt sind, können sie nur auf Elemente innerhalb dieser Webkomponente angewendet werden. Das ist einer der großen Freuden beim Schreiben von Webkomponenten: Selektoren können einfacher sein und CSS ist einfacher zu lesen und zu schreiben. Viele Grüße BEM

Aktualisieren Sie die Stile so:

export class BrickViewer extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
    }
    #controls {
      position: absolute;
      bottom: 0;
      width: 100%;
      display: flex;
    }
  `;
}

Ein Element für den Ansichtsmodus mit Schaltflächen zum Neustarten, Zurück- und Vorspulen.

Taste zum Zurücksetzen der Kamera

Endnutzer unserer <brick-viewer> können die Szene mit der Maus drehen. Während wir weitere Schaltflächen hinzufügen, füge ich eine hinzu, mit der die Kamera auf die Standardposition zurückgesetzt werden kann. Eine weitere <mwc-icon-button> mit einer Klickereignisbindung reicht aus.

export class BrickViewer extends LitElement {
  private _resetCamera() {
    this._controls.reset();
  }

  render() {
    return html`
      <div id="controls">
        <!-- ... -->
        <!-- Add this button: -->
        <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
      </div>
    `;
  }
}

Schnellere Navigation

Einige Bausteine haben viele Stufen. Ein Nutzer möchte möglicherweise zu einem bestimmten Schritt springen. Ein Schieberegler mit Schrittnummern kann die Navigation erleichtern. Dazu verwenden wir das Element <mwc-slider>.

MWC-Schieberegler

Für das Schiebereglerelement sind einige wichtige Daten erforderlich, z. B. der Mindest- und Höchstwert des Schiebereglers. Der Mindestwert des Schiebereglers kann immer „1“ sein. Der maximale Schiebereglerwert sollte this._numConstructionSteps sein, wenn das Modell geladen wurde. Diese Werte können wir <mwc-slider> anhand ihrer Attribute mitteilen. Wir können auch die ifDefined-Anweisung „lit-html“ verwenden, um das Attribut max nicht festzulegen, wenn die Eigenschaft _numConstructionSteps nicht definiert wurde.

Fügen Sie zwischen den Schaltflächen „Zurück“ und „Weiter“ ein <mwc-slider> ein:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ... backwards button -->
        <!-- Add this slider: -->
        <mwc-slider
            step="1"
            pin
            markers
            min="1"
            max=${ifDefined(this._numConstructionSteps)}
        ></mwc-slider>
        <!-- ... forwards button -->
      </div>
    `;
  }
}

Daten „nach oben“

Wenn ein Nutzer den Schieberegler bewegt, sollte sich der aktuelle Konstruktionsschritt ändern und die Sichtbarkeit des Modells entsprechend aktualisiert werden. Das Schiebereglerelement gibt jedes Mal ein Eingabeereignis aus, wenn der Schieberegler gezogen wird. Fügen Sie dem Schieberegler selbst eine Ereignisbindung hinzu, um dieses Ereignis zu erfassen und den Konstruktionsschritt zu ändern.

Fügen Sie die Ereignisbindung hinzu:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the @input event binding: -->
        <mwc-slider
            ...
            @input=${(e: CustomEvent) => this.step = e.detail.value}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Sehr gut! Mit dem Schieberegler können Sie ändern, welcher Schritt angezeigt wird.

„Daten nicht verfügbar“

Es gibt noch etwas. Wenn Sie den Schritt über die Schaltflächen „Zurück“ und „Weiter“ ändern, muss der Schieberegler aktualisiert werden. Binden Sie das Attribut „Wert“ von <mwc-slider> an this.step.

Fügen Sie die value-Bindung hinzu:

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the value property binding: -->
        <mwc-slider
            ...
            value=${ifDefined(this.step)}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

Wir sind fast fertig mit dem Schieberegler. Fügen Sie einen Flex-Stil hinzu, damit er gut zu den anderen Steuerelementen passt:

export class BrickViewer extends LitElement {
  static styles = css`
    /* ... */
    mwc-slider {
      flex-grow: 1;
    }
  `;
}

Außerdem müssen wir layout auf das Schiebereglerelement selbst anwenden. Das geschieht in der Lebenszyklusmethode firstUpdated, die aufgerufen wird, sobald das DOM zum ersten Mal dargestellt wurde. Mit dem query-Decorator können wir einen Verweis auf das Schiebereglerelement in der Vorlage abrufen.

export class BrickViewer extends LitElement {
  @query('mwc-slider')
  slider!: Slider|null;

  async firstUpdated() {
    if (this.slider) {
      await this.slider.updateComplete
      this.slider.layout();
    }
  }
}

Hier sehen Sie alle Ergänzungen der Schieberegler zusammen (mit zusätzlichen pin- und markers-Attributen auf dem Schieberegler, damit der Schieberegler cooler wirkt):

export class BrickViewer extends LitElement {
 @query('mwc-slider')
 slider!: Slider|null;

 static styles = css`
   /* ... */
   mwc-slider {
     flex-grow: 1;
   }
 `;

 async firstUpdated() {
   if (this.slider) {
     await this.slider.updateComplete
     this.slider.layout();
   }
 }

 render() {
   return html`
     ${this._renderer.domElement}
     <div id="controls">
       <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
       <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
       <mwc-slider
         step="1"
         pin
         markers
         min="1"
         max=${ifDefined(this._numConstructionSteps)}
         ?disabled=${this._numConstructionSteps === undefined}
         value=${ifDefined(this.step)}
         @input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
       ></mwc-slider>
       <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
       <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
     </div>
   `;
 }
}

Hier ist das Endprodukt.

Navigation in einem Autobaustein mit dem Ziegel-Viewer-Element

7. Fazit

Wir haben viel darüber gelernt, wie wir mithilfe von Lit-Elementen unser eigenes HTML-Element erstellen können. Wir haben gelernt, wie man:

  • Benutzerdefiniertes Element definieren
  • Attribut-API deklarieren
  • Ansicht für ein benutzerdefiniertes Element rendern
  • Stile einschließen
  • Daten mithilfe von Ereignissen und Properties übergeben

Weitere Informationen zu Licht-Elementen findest du auf der offiziellen Website.

Ein fertiges Brick-Viewer-Element finden Sie unter stackblitz.com/edit/brick-viewer-complete.

„brick-viewer“ ist auch über NPM verfügbar. Den Quellcode finden Sie hier: Github-Repository.