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 Element <brick-viewer>, mit dem Sie Brick-Modelle anzeigen können.

lit-element

Um unser benutzerdefiniertes Element <brick-viewer> zu definieren, verwenden wir lit-element. lit-element ist eine einfache Basisklasse, die dem Webkomponentenstandard einige syntaktische Erweiterungen hinzufügt. So können wir unser benutzerdefiniertes Element ganz einfach einrichten und verwenden.

Jetzt starten

Wir programmieren in einer Stackblitz-Onlineumgebung. Öffnen Sie diesen Link in einem neuen Fenster:

stackblitz.com/edit/brick-viewer

Los geht's!

2. Benutzerdefiniertes Element definieren

Klassendefinition

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

Geben Sie in „brick-viewer.ts“ Folgendes ein:

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

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

Methode „render“

Um die Ansicht der Komponente zu implementieren, definieren Sie eine Methode mit dem Namen „render“. Diese Methode sollte ein Template-Literal zurückgeben, das mit der Funktion html getaggt ist. Fügen Sie dem getaggten Template-Literal den gewünschten HTML-Code hinzu. 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

Attribut definieren

Es wäre praktisch, wenn ein Nutzer des Elements <brick-viewer> über ein Attribut angeben könnte, welches Brick-Modell angezeigt werden soll, z. B. so:

<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 bei einem <img> oder <video> Tag. Mit lit-element ist das ganz einfach: Sie müssen nur ein Klassenattribut mit @property versehen. Mit der Option type können Sie angeben, wie lit-element das Attribut für die Verwendung als HTML-Attribut parsen soll.

Definieren Sie 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. Der Wert ist dank lit-element bereits in unserer Klasse BrickViewer lesbar.

Werte anzeigen

Wir können den Wert des Attributs src anzeigen, indem wir es im Template-Literal der Methode „render“ verwenden. Verwenden Sie die Syntax ${value}, um Werte in Template-Literale zu interpolieren.

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

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

...Haben Sie bemerkt, dass der Text im Element automatisch aktualisiert wird? lit-element beobachtet die Klassenattribute, die mit @property versehen sind, und rendert die Ansicht neu. lit-element erledigt die Arbeit für Sie.

4. Szene mit Three.js einrichten

Licht, Kamera, Rendern!

Unser benutzerdefiniertes Element verwendet three.js, um unsere 3D-Brick-Modelle zu rendern. Einige Dinge müssen 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 diese dem Konstruktor der Klasse „BrickViewer“ hinzu. Einige Objekte behalten wir als Klassenattribute bei, damit wir sie später verwenden können: Kamera, Szene, Steuerelemente und Renderer.

Fügen Sie die Einrichtung der three.js-Szene 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 Objekt WebGLRenderer stellt ein DOM-Element bereit, in dem die gerenderte three.js-Szene angezeigt wird. Der Zugriff erfolgt über das Attribut domElement. Wir können diesen Wert mit der Syntax ${value} in das Template-Literal „render“ interpolieren.

Entfernen Sie die Nachricht src 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 werden kann, müssen wir auch das <brick-viewer> Element selbst auf display: block setzen. Wir können Stile in einem statischen Attribut namens styles angeben, das auf ein css-Template-Literal gesetzt 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, in dem eine gerenderte, aber leere Szene angezeigt wird.

Aber... sie ist leer. Fügen wir ein Modell hinzu.

Brick-Loader

Wir übergeben das zuvor definierte Attribut src an den LDrawLoader, der in three.js enthalten ist.

In LDraw-Dateien kann ein Brick-Modell in separate Bauschritte unterteilt werden. Die Gesamtzahl der Schritte und die Sichtbarkeit einzelner Bricks sind über die LDrawLoader API zugänglich.

Kopieren Sie diese Attribute, 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 sollte _loadModel aufgerufen werden? Sie muss jedes Mal aufgerufen werden, wenn sich das Attribut „src“ ändert. Indem wir das Attribut src mit @property versehen, haben wir es in den lit-element-Aktualisierungszyklus aufgenommen. Wenn sich der Wert eines dieser Attribute ändert, wird eine Reihe von Methoden aufgerufen, die auf die neuen und alten Werte der Attribute zugreifen können. Die Lifecycle-Methode, die uns interessiert, heißt update. Die Methode update verwendet ein PropertyValues-Argument, das Informationen zu allen Attributen enthält, die sich gerade geändert haben. Hier ist der perfekte Ort, um _loadModel aufzurufen.

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 anzeigen, die mit dem src Attribut angegeben wurde.

Ein Brick-Viewer-Element, in dem ein Modell eines Autos angezeigt wird.

5. Teilmodelle anzeigen

Jetzt machen wir den aktuellen Bauschritt konfigurierbar. Wir möchten <brick-viewer step="5"></brick-viewer> angeben können und sehen, wie das Brick-Modell im 5. Bauschritt aussieht. Dazu machen wir das Attribut step zu einem beobachteten Attribut, indem wir es mit @property versehen.

Versehen Sie das Attribut step mit `@property`:

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

Jetzt fügen wir eine Hilfsmethode hinzu, mit der nur die Bricks bis zum aktuellen Bauschritt sichtbar sind. Wir rufen die Hilfsmethode in der Methode „update“ auf, damit sie jedes Mal ausgeführt wird, wenn sich das Attribut step ändert.

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);
  }
}

Okay, öffnen Sie jetzt die Entwicklertools Ihres Browsers und untersuchen Sie das <brick-viewer> Element. Fügen Sie ihm ein Attribut step hinzu, z. B. so:

HTML-Code eines Brick-Viewer-Elements, bei dem das Attribut „step“ auf 10 festgelegt ist.

Beobachten Sie, was mit dem gerenderten Modell passiert. Mit dem Attribut step können wir steuern, wie viel vom Modell angezeigt wird. So sollte es aussehen, wenn das Attribut step auf "10" gesetzt ist:

Ein Ziegelmodell mit nur zehn Bauschritten.

6. Navigation im Brick-Set

mwc-icon-button

Der Endnutzer unseres <brick-viewer> sollte auch über die Benutzeroberfläche durch die Bauschritte navigieren können. Fügen wir Schaltflächen hinzu, mit denen man zum nächsten, vorherigen und ersten Schritt wechseln kann. Wir verwenden die Webkomponente „Button“ von Material Design, um es uns einfach zu machen. Da @material/mwc-icon-button bereits importiert wurde, können wir <mwc-icon-button></mwc-icon-button> einfügen. Mit dem Attribut „icon“ können wir das gewünschte Symbol angeben, z. B. so: <mwc-icon-button icon="thumb_up"></mwc-icon-button>. Alle möglichen Symbole finden Sie unter material.io/resources/icons.

Fügen wir der Methode „render“ 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 ganz einfach, Material Design auf unserer Seite zu verwenden.

Ereignisbindungen

Diese Schaltflächen sollten auch etwas tun. Mit der Schaltfläche „reply“ sollte der Bauschritt auf 1 zurückgesetzt werden. Mit der Schaltfläche „navigate_before“ sollte der Bauschritt verringert und mit der Schaltfläche „navigate_next“ erhöht werden. Mit lit-element ist es ganz einfach, diese Funktion mit Ereignisbindungen hinzuzufügen. Verwenden Sie in Ihrem HTML-Template-Literal die Syntax @eventname=${eventHandler} als Elementattribut. eventHandler wird jetzt ausgeführt, wenn in diesem Element ein eventname-Ereignis erkannt wird. Fügen wir beispielsweise Klickereignishandler zu unseren drei Symbolschaltflächen 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>
    `;
  }
}

Klicken Sie jetzt auf die Schaltflächen. Sehr gut!

Stile

Die Schaltflächen funktionieren, sehen aber nicht gut aus. Sie sind alle unten zusammengepfercht. Wir gestalten sie so, dass sie über der Szene liegen.

Um Stile auf diese Schaltflächen anzuwenden, kehren wir zum Attribut static styles zurück. Diese Stile sind begrenzt, d. h., sie werden nur auf Elemente innerhalb dieser Webkomponente angewendet. Das ist einer der Vorteile von Webkomponenten: Selektoren können einfacher sein und CSS ist leichter zu lesen und zu schreiben. Tschüss, BEM!

Aktualisieren Sie die Stile so, dass sie so aussehen:

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

Ein Element zur Ziegelansicht mit Schaltflächen zum Neustarten, Zurückspulen und Vorspulen.

Schaltfläche „Kamera zurücksetzen“

Endnutzer unseres <brick-viewer> können die Szene mit der Maus drehen. Da wir gerade Schaltflächen hinzufügen, fügen wir auch eine Schaltfläche hinzu, mit der die Kamera auf ihre 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 Brick-Sets haben viele Schritte. Ein Nutzer möchte möglicherweise zu einem bestimmten Schritt springen. Ein Schieberegler mit Schrittnummern kann die Navigation erleichtern. Dazu verwenden wir das <mwc-slider> Element.

mwc-slider

Das Schiebereglerelement benötigt einige wichtige Daten, z. B. den Mindest- und Höchstwert des Schiebereglers. Der Mindestwert des Schiebereglers kann immer „1“ sein. Der Höchstwert des Schiebereglers sollte this._numConstructionSteps sein, wenn das Modell geladen wurde. Wir können <mwc-slider> diese Werte über seine Attribute mitteilen. Wir können auch die lit-html- Direktive ifDefined verwenden, um das Attribut max nicht festzulegen, wenn das Attribut _numConstructionSteps nicht definiert wurde.

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

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 Bauschritt ändern und die Sichtbarkeit des Modells entsprechend aktualisiert werden. Das Schiebereglerelement gibt ein Eingabeereignis aus, wenn der Schieberegler gezogen wird. Fügen Sie eine Ereignisbindung für den Schieberegler selbst hinzu, um dieses Ereignis abzufangen und den Bauschritt 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>
    `;
  }
}

Waaah! Mit dem Schieberegler können wir ändern, welcher Schritt angezeigt wird.

Daten „nach unten“

Es gibt noch etwas. Wenn die Schaltflächen „Zurück“ und „Weiter“ verwendet werden, um den Schritt zu ändern, muss der Schieberegler aktualisiert werden. Binden Sie das Attribut „value“ 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 fast fertig mit dem Schieberegler. Fügen Sie einen Flex-Stil hinzu, damit er gut mit den anderen Steuerelementen zusammenarbeitet:

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

Außerdem müssen wir die Methode layout für das Schiebereglerelement selbst aufrufen. Das tun wir in der Lifecycle-Methode firstUpdated, die aufgerufen wird, sobald das DOM zum ersten Mal angelegt wurde. Mit dem Dekorator query können wir einen Verweis auf das Schiebereglerelement in der Vorlage erhalten.

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

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

Hier sind alle Ergänzungen für den Schieberegler zusammengefasst (mit zusätzlichen Attributen pin und markers für den Schieberegler, damit er cool aussieht):

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.

Mit dem Element „brick-viewer“ ein Auto-Brick-Modell navigieren

7. Fazit

Wir haben viel darüber gelernt, wie wir mit lit-element unser eigenes HTML-Element erstellen können. Wir haben gelernt, wie man:

  • ein benutzerdefiniertes Element definiert
  • eine Attribut-API deklariert
  • eine Ansicht für ein benutzerdefiniertes Element rendert
  • Stile kapselt
  • Ereignisse und Attribute verwendet, um Daten zu übergeben

Weitere Informationen zu lit-element finden Sie auf der offiziellen Website.

Ein fertiges „brick-viewer“-Element finden Sie unter stackblitz.com/edit/brick-viewer-complete.

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