Brick Viewer mit Lichtelementen bauen

1. Einführung

Webkomponenten

Webkomponenten sind eine Sammlung von Webstandards, die es Entwicklern ermöglichen, HTML-Code um benutzerdefinierte Elemente zu erweitern. In diesem Codelab definieren Sie das <brick-viewer>-Element, mit dem Steinmodelle angezeigt werden können.

Leuchtelement

Zur Definition des benutzerdefinierten Elements <brick-viewer> verwenden wir das 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

Die Programmierung erfolgt in einer Stackblitz-Onlineumgebung. Öffnen Sie daher 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 dekorieren Sie sie mit @customElement. Das Argument für @customElement ist der Name des benutzerdefinierten Elements.

Gib 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. Aber wenn Sie es ausprobieren, wird nichts gerendert. Das sollte nicht sein.

Renderingmethode

Um die Ansicht der Komponente zu implementieren, definieren Sie eine Methode namens 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. 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 genau wie ein <img>- oder <video>-Tag definieren. Mit Lit-Elementen ist es genauso einfach wie das Dekorieren einer Klasseneigenschaft mit @property. Mit der Option type können Sie angeben, wie das Lichtelement die Eigenschaft parst, um sie als HTML-Attribut zu verwenden.

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 ihn im Vorlagenliteral der Renderingmethode verwenden. Interpolieren Sie Werte mit der ${value}-Syntax in Vorlagenliterale.

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

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

Haben Sie bemerkt, dass der Text im Element automatisch aktualisiert wird? Das lit-Element beobachtet die mit @property dekorierten Klasseneigenschaften und rendert die Ansicht noch einmal für Sie. übernimmt den Großteil der Arbeit, sodass Sie sich nicht darum kümmern 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 für jede Instanz eines <brick-viewer>-Elements nur einmal ausführen, z. B. die 3.js-Szene, die Kamera und die Beleuchtung einrichten. Wir fügen diese dem Konstruktor der BrickViewer-Klasse hinzu. Wir behalten einige Objekte als Klasseneigenschaften bei, damit wir sie später verwenden 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, mit dem die gerenderte Three.js-Szene angezeigt wird. Der Zugriff erfolgt über die Property domElement. Wir können diesen Wert mithilfe der Syntax ${value} in das Renderingvorlagenliteral interpolieren.

Entfernen Sie die src-Nachricht 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 Eigenschaft namens styles bereitstellen, 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;
    }
  `;
}

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

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

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

Ziegellader

Wir übergeben die zuvor definierte src-Eigenschaft an den LDrawLoader, der mit drei.js ausgeliefert wird.

Mit LDraw-Dateien können Sie ein Ziegelmodell in separate Gebäudeschritte unterteilen. Über die LDrawLoader API kann auf die Gesamtzahl der Schritte und die Sichtbarkeit einzelner Bausteine zugegriffen werden.

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 Attribut „src“ ändert. Durch das Dekorieren der src-Eigenschaft mit @property haben wir die Eigenschaft für den Aktualisierungszyklus des Litigation-Elements aktiviert. Wenn eine dieser dekorierten Unterkünfte 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);
  }
}

Unser <brick-viewer>-Element kann jetzt eine Brick-Datei darstellen, die mit dem src-Attribut angegeben wird.

Ein Element, das ein Automodell zeigt

5. Teilmodelle anzeigen

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

Dekoriere die Property step:

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

Jetzt fügen wir eine Hilfsmethode hinzu, die nur die Bausteine bis zum aktuellen Schritt sichtbar macht. 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 Methode update und fügen Sie die neue Methode _updateBricksVisibility 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 Entwicklertools 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 einem Schrittattribut auf 10 gesetzt.

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 Attribut step auf "10" gesetzt ist:

Ein Backsteinmodell mit nur zehn Baustufen.

6. Navigation im Baustein

Symbol-Schaltfläche für MWC

Der Endnutzer unseres <brick-viewer> sollte auch in der Lage sein, die Build-Schritte über die Benutzeroberfläche aufzurufen. Jetzt fügen wir Schaltflächen für den nächsten, vorherigen und ersten Schritt hinzu. Wir verwenden die Schaltflächen-Webkomponente von Material Design, um dies zu vereinfachen. Da @material/mwc-icon-button bereits importiert wurde, können wir <mwc-icon-button></mwc-icon-button> einfügen. So können wir das Symbol angeben, das mit dem icon-Attribut verwendet werden soll: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Alle möglichen Symbole finden Sie hier: material.io/resources/icons.

Fügen wir der Rendering-Methode 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 der Webkomponenten ist die Verwendung von Material Design auf unserer Seite so einfach.

Ereignisbindungen

Diese Schaltflächen sollten eigentlich eine Funktion haben. Die Antwort Schaltfläche sollte den Bauschritt auf 1 zurücksetzen. Der Parameter „navi_before“ -Schaltfläche sollte die Zahl der Bauschritte verringern und die Schaltfläche -Schaltfläche erhöht werden soll. Mit dem lit-Element können Sie diese Funktion mithilfe von Ereignisbindungen 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, aber sie sehen nicht gut aus. Sie sind alle unten eingedrungen. Lassen Sie uns diese so gestalten, dass sie in der Szene eingeblendet werden.

Um Stile auf diese Schaltflächen anzuwenden, kehren wir zur static styles-Eigenschaft 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. Tschüss, BEM!

Ändern Sie die Stile so, dass sie wie folgt aussehen:

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

Ein Brick-Viewer-Element mit Schaltflächen zum Neustarten, Zurückgehen und Vorwärts

Schaltfläche „Kamera zurücksetzen“

Endnutzer unseres <brick-viewer> können die Szene mit der Maus drehen. Während wir weitere Schaltflächen hinzufügen, füge ich eine hinzu, um die Kamera auf die Standardposition zurückzusetzen. Ein anderer <mwc-icon-button> mit einer Klickereignisbindung erledigt den Job.

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. Wenn Sie einen Schieberegler mit den Schrittnummern hinzufügen, können Sie schneller navigieren. 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 ifDefinedlit-html-Anweisung verwenden, um das Festlegen des Attributs max zu vermeiden, wenn die Eigenschaft _numConstructionSteps nicht definiert wurde.

<mwc-slider> zwischen „Zurück“ hinzufügen und „vorwärts“ Schaltflächen:

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 Erstellungsschritt 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 „nach unten“

Es gibt noch eine Sache. Wenn der „Zurück“-Pfeil und „Weiter“ verwenden, um den Schritt zu ändern, muss der Ziehpunkt des Schiebereglers aktualisiert werden. Binden Sie das Wertattribut von <mwc-slider> an this.step.

Fügen Sie die Bindung value 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 mit dem Schieberegler fast fertig. Fügen Sie einen flexiblen Stil hinzu, damit es harmonisch zu den anderen Steuerelementen passt:

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

Außerdem muss layout für das Schiebereglerelement selbst aufgerufen werden. Dies geschieht in der Lebenszyklusmethode firstUpdated, die aufgerufen wird, sobald das erste DOM-Layout erstellt 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>
   `;
 }
}

Das 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 Folgendes gelernt:

  • 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.

Sie können sich ein fertiggestelltes Brick-Viewer-Element unter stackblitz.com/edit/brick-viewer-complete ansehen.

Brick-Viewer ist auch über NPM verfügbar. Die Quelle finden Sie hier: GitHub-Repository.